Compare commits

...

79 Commits

Author SHA1 Message Date
Hocuri
a4fcb872f7 test: Test that messages are only marked as delivered after being fully sent out 2026-04-02 15:15:08 +02:00
Hocuri
626ac8161a fix: Mark a message as delivered only after it has been fully sent out (#8062)
Fix https://github.com/chatmail/core/issues/8042

The problem was that after receiving the bcc_self'ed pre-message in
`receive_imf`, the logic there only looked for a pending
`smtp`-table-entry that matches the rfc724_mid, and if there was none
then it thought "Great, apparently the message is fully sent out, we can
mark it as delivered!".

But with pre-messages, the same message can have two `smtp` entries (one
for the pre-message and one for the post-message), and the message
should only be marked as delivered once both of them are sent out.

Now, I changed the logic to look for all entries with the same msg_id.
This is actually the same SQL query used in smtp.rs, so, I extracted it
into a new function; feel free to suggest a better name for it.

I tested on Android that it now works fine.

I'll add a test in a follow-up PR.

There are a lot of other problems with sending large files, though:
- The pre-message is sent before the post-message, so that for the
receiver it looks as if the message arrived, but stays in
"downloading..." forever
- There is quite a time delay between clicking on "Send" and the
outgoing message appearing in the chat
- The message shortly gets a letter icon right after it is sent
- I'm wondering if there is a way to give feedback to the user
immediately if the message is too big
- It's unclear when exactly we want to send read receipts

I'll open a follow-up issue for these.
2026-04-02 15:12:17 +02:00
holger krekel
28cce5e31d fix: determine whether a message is an own message by looking at signature. multiple devices can temporarly have different sets of self addresses, and still need to properly recognize incoming versus outgoing messages. Disclaimer: some LLM tooling was initially involved but i went over everything by hand, and also addressed review comments. 2026-04-01 14:51:48 +02:00
link2xt
3b87e27f34 docs: document that events are broadcasted to all event emitters
Delta Chat for iOS already relies on this behavior,
so it cannot be practically changed.
2026-04-01 09:03:07 +00:00
link2xt
24b21c0588 chore(release): prepare for 2.48.0 2026-03-30 12:48:24 +02:00
link2xt
eb666d4cc3 test: the message is sorted correctly in the chat even if it arrives late 2026-03-30 08:52:19 +00:00
link2xt
ef265689dd fix: do not sort received messages below the last seen one 2026-03-30 08:52:19 +00:00
link2xt
49223792f9 fix: never sort the message before chat joining timestamp
This is to avoid sorting incoming messages that
are slightly in the past above system messages
about SecureJoin. SecureJoin messages are
timed according to smeared timestamp,
so even in the local tests they are in the future
by a few seconds.
2026-03-30 08:52:19 +00:00
Hocuri
920da083d1 fix: Manipulate sort_timestamp to not be 0 2026-03-30 08:52:19 +00:00
link2xt
8f1bf963b4 fix: always sort "Messages are end-to-end encrypted" notice to the beginning
We set timestamp of this info message to 0
to make it always appear in the beginning of the chat.
To avoid new chats being sorted to the end of the chatlist,
we ignore such 0 and use chat creation timestamp
when sorting the chatlist.
2026-03-30 08:52:19 +00:00
link2xt
e33d50b4e0 test: use load_imf_email() more 2026-03-30 08:52:19 +00:00
link2xt
f1dc03a4ee test: do not rely on loading newest chat in load_imf_email()
We know which message was added from the return value
of receive_imf(). It may be that the first chat
in the chatlist is not the one where the message was received
if there is a pinned chat or if
just received message is old.
2026-03-30 08:52:19 +00:00
link2xt
5d90cc7a2a test: remove test_old_message_5
It is not clear now what this is testing.
Golden test shows messages ordered
incorrectly according to the timestamps,
they should be ordered the other way round.

Comment talks about fetching from mvbox and inbox
in paralell which is a rare case that
could have happened if one message is left in the inbox
and the other message is a chat message moved to mvbox.
We never download anything that is not moved to the target folder.

The test also resides in "verified chats" tests
which are all legacy tests we kept after
replacing the concept of verified/protected chats
with key contacts in 2.x.
2026-03-30 08:52:19 +00:00
link2xt
68e630eb82 fix: remove migration 108
This removes migration added in 625887d249
2026-03-30 08:38:28 +00:00
iequidoo
ef718bb869 fix: When receiving MDN, mark all preceding messages as noticed, even having same timestamp (#7928)
This fixes flaky JSON-RPC `test_multidevice_sync_seen`.
2026-03-29 11:50:50 -03:00
iequidoo
f1860f90d4 feat: Log received message sort timestamp
This way it's easier to debug issues like `MsgsNoticed` not emitted for a chat.
2026-03-29 11:50:50 -03:00
link2xt
a947f4296f refactor(securejoin): do not check for self address in forwarding protection
If our key is gossiped, the message is intended for us.
The check for address is redundant for incoming messages as
if we received the message then it was addressed to us.

This whole protection code can eventually be removed
as we have intended recipient fingerprints already,
it only protects against forwarding of messages
sent by old clients.
2026-03-28 16:20:39 +00:00
link2xt
8c3139f7a2 feat: add decryption error to the device message about outgoing message decryption failure 2026-03-28 13:27:15 +00:00
link2xt
3dd7defaa1 docs: add SQL performance tips to STYLE.md 2026-03-28 10:08:54 +00:00
link2xt
3096dd6027 ci: fix https://docs.zizmor.sh/audits/#bot-conditions 2026-03-28 09:29:12 +01:00
link2xt
ee62d2d281 ci: use environment for js.jsonrpc.delta.chat deployment 2026-03-28 01:06:12 +01:00
link2xt
6095971f67 ci: use environment for cffi.delta.chat deployment 2026-03-27 16:37:58 +01:00
link2xt
32ff5b7a6b ci: use environment for rs.delta.chat deployment 2026-03-27 09:36:11 +00:00
link2xt
b87805ab24 fix: cleanup imap and imap_sync records without transport in housekeeping
Previously transports deleted via sync messages left unused `imap` entries.
2026-03-26 16:24:11 +00:00
link2xt
c8716ad85a fix: delete imap_markseen entries not corresponding to any imap rows 2026-03-26 16:24:11 +00:00
link2xt
4dd0ba2c72 fix: move sorting outside of SQL query in store_seen_flags_on_imap
With `ORDER BY` statement SQLite searches
the `imap` table by `transport_id` and for each found row
scans the whole `imap_markseen` table.
Number of `imap` entries for each `transport_id`
is usually large as we need to know
which UIDs to delete on IMAP server
when deleting a message.

```
sqlite> EXPLAIN QUERY PLAN
SELECT imap.id, uid, folder FROM imap, imap_markseen
WHERE imap.id = imap_markseen.id
AND imap.transport_id=?
AND target = folder
ORDER BY folder, uid;
QUERY PLAN
|--SEARCH imap USING INDEX sqlite_autoindex_imap_1 (transport_id=?)
`--SCAN imap_markseen
```

Without `ORDER BY` statement SQLite scans `imap_markseen`
table which is expected to be small,
and then searches `imap` table by `rowid` for each found result.

```
sqlite> EXPLAIN QUERY PLAN
SELECT imap.id, uid, folder FROM imap, imap_markseen
WHERE imap.id = imap_markseen.id
AND imap.transport_id=?
AND target = folder;
QUERY PLAN
|--SCAN imap_markseen
`--SEARCH imap USING INTEGER PRIMARY KEY (rowid=?)
```

Query planning was tested with SQLite 3.52.0.
It is possible to explictly make
query planner move sorting to the last step
with `ORDER +folder, +uid`, but this is not recommended
in SQLite documentation
(see <https://www.sqlite.org/optoverview.html#uplus>).

It is also possible to add indexes,
but indexes use space,
adding them requires an SQL migration,
and each index needs to be updated so it will slow down writes.
2026-03-26 16:24:11 +00:00
link2xt
a24248a90b ci: update {c,py}.delta.chat website deployments
The host has been changed and the secrets are moved to environments.
2026-03-26 15:25:53 +00:00
iequidoo
af16fc9038 fix: Make Message-ID of pre-messages stable across resends (#8007) 2026-03-25 23:32:33 -03:00
link2xt
c99b8a4482 feat: improve IMAP loop logs
Only inbox loop is changed because non-inbox loop is going to be removed
together with `mvbox_move`.

Added transport IDs to the log and logging around quota updates.
Removed some logs that add noise,
like logging that IDLE is supported each time right before using it.
2026-03-25 20:31:53 +00:00
link2xt
76e2c36d85 refactor: cleanup remaining Autocrypt Setup Message processing in mimeparser 2026-03-25 19:54:19 +00:00
link2xt
1b8bf4ed23 api: add JSON-RPC API markfresh_chat() 2026-03-25 19:53:44 +00:00
link2xt
c553357c60 docs: move changelog entry for dc_markfresh_chat to API changes 2026-03-25 19:53:44 +00:00
link2xt
ebe8550c52 chore: fix clippy warnings 2026-03-25 19:53:10 +00:00
link2xt
2637c3bea4 refactor: replace async RwLock with sync RwLock for stock strings 2026-03-25 19:48:40 +00:00
iequidoo
d1f1633c60 refactor: Remove wal_checkpoint_mutex, lock write_mutex before getting sql connection instead
The original idea was to always lock `write_mutex` before acquiring an `InnerPool.semaphore` permit
to avoid ABBA deadlocks, but when refactoring a PR for b696a242fc,
that was forgotten.

This doesn't really change the program flow as we have `Context::housekeeping_mutex` anyway,
just simplifies the code.
2026-03-25 06:13:10 -03:00
iequidoo
98b55ec15f refactor(ffi): Correctly declare dc_event_channel_new() as having no params (#7831)
In C, `foo()` means that the function accepts an unspecified number of arguments and this is
deprecated.
2026-03-24 16:22:35 -03:00
link2xt
6a3ef20a99 chore(cargo): update rustls-webpki to 0.103.10
Upgrading fixes RUSTSEC-2026-0049 for our usage
of TLS for SMTP and IMAP.

This introduces duplicate dependency because iroh
still depends 0.102.
2026-03-24 12:09:20 +00:00
link2xt
59be03a7eb chore: bump version to 2.48.0-dev 2026-03-24 04:30:06 +01:00
link2xt
8528184fa3 chore(release): prepare for 2.47.0 2026-03-24 04:07:52 +01:00
link2xt
5ab1fdca2e feat: use SEIPDv2 if all recipients support it 2026-03-24 02:37:40 +00:00
link2xt
f616d1bd6c refactor: remove code to send messages without intended recipient fingerprint 2026-03-23 22:45:10 +00:00
link2xt
e885e052c3 test: make add_or_lookup_contact_id_no_key public 2026-03-23 22:45:10 +00:00
link2xt
6b1e62faba fix: delete available_post_msgs row if there is no corresponding IMAP entry
If we learn about this message being available on IMAP later,
we will add another available_post_msgs row.
If we don't delete the row, we will keep failing each time
until IMAP entry becomes available and it may not happen.
2026-03-23 22:01:16 +00:00
link2xt
7b9e7ae611 fix: delete available_post_msgs row if the message is already downloaded
The row does not need to stay in the database
only to be skipped each time.
2026-03-23 22:01:16 +00:00
link2xt
aedc60f1cc docs: document Header Confidentiality Policy 2026-03-23 21:08:11 +00:00
link2xt
017099215c chore: add RUSTSEC-2026-0049 exception to deny.toml
We cannot upgrade the crate because it is a transitive dependency
and the issue described in
<https://rustsec.org/advisories/RUSTSEC-2026-0049>
is not dangerous because it requiers a compromised CA
and revoked certificate. Worst case that happens
with iroh is that outer layer of encryption to
iroh relay is compromised, but iroh traffic is
still encrypted between peers without relying on CAs.
2026-03-23 19:49:49 +00:00
Hocuri
e86b170969 fix: Don't fall into infinite loop if the folder is missing (#8021)
Previously, if the mvbox_move folder is missing, then core will loop
infinitely, because `new_mail` is never set to false.

The fix is to first set `new_mail` to false, then return if the folder
is missing.

This is the bug @hpk42 experienced when commenting in
https://github.com/chatmail/core/issues/7989

---------

Co-authored-by: holger krekel <holger@merlinux.eu>
2026-03-23 18:29:49 +01:00
link2xt
452ac8a1bc docs: remove draft/aeap-mvp.md
AEAP is superseded by key-contacts and multi-relay.
2026-03-22 06:23:58 +00:00
Hocuri
5d06ca3c8e fix: Make newlines work in chat descriptions (#8012)
This fixes a bug: If there is a multi-line chat description, only the
first line was shown on recipient devices.

Credits to @lk108 for noticing!
2026-03-21 14:48:56 +01:00
link2xt
bdc9e7ce56 fix(deltachat_rpc_client): make sphinx documentation display method parameters 2026-03-20 08:30:06 +00:00
missytake
e30d833c94 docs: add shadowsocks spec to standards.md 2026-03-20 02:44:43 +00:00
dependabot[bot]
16668b45e9 chore(cargo): bump sdp from 0.10.0 to 0.17.1
Bumps [sdp](https://github.com/webrtc-rs/webrtc) from 0.10.0 to 0.17.1.
- [Release notes](https://github.com/webrtc-rs/webrtc/releases)
- [Commits](https://github.com/webrtc-rs/webrtc/compare/v0.10.0...v0.17.1)

---
updated-dependencies:
- dependency-name: sdp
  dependency-version: 0.17.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 02:41:11 +00:00
Hocuri
b148be2618 chore: bump version to 2.47.0-dev 2026-03-19 11:02:47 +01:00
Hocuri
191e6c2821 chore(release): prepare for 2.46.0 2026-03-19 10:58:42 +01:00
link2xt
7b700591f4 chore: add constant_time_eq 0.3.1 to deny.toml 2026-03-19 02:16:37 +00:00
dependabot[bot]
98f03743c6 chore(cargo): bump blake3 from 1.8.2 to 1.8.3
Bumps [blake3](https://github.com/BLAKE3-team/BLAKE3) from 1.8.2 to 1.8.3.
- [Release notes](https://github.com/BLAKE3-team/BLAKE3/releases)
- [Commits](https://github.com/BLAKE3-team/BLAKE3/compare/1.8.2...1.8.3)

---
updated-dependencies:
- dependency-name: blake3
  dependency-version: 1.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 02:16:37 +00:00
link2xt
bcaf1284e2 feat(tls): do not verify TLS certificates for hostnames starting with _ 2026-03-18 17:51:03 +00:00
Hocuri
fba4e63961 api: Rename Transport to TransportListEntry (#8009)
Follow-up to https://github.com/chatmail/core/pull/7994/, in order to
prevent clashes with other things that are called `Transport`, and in
order to make the struct name more greppable
2026-03-18 16:17:53 +01:00
Hocuri
810dab12dc api: Add list_transports_ex() and set_transport_unpublished() functions
Closes https://github.com/chatmail/core/issues/7980.

Unpublished transports are not advertised to contacts, 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 the new relay addresses yet.

- This adds `list_transports_ex()` and `set_transport_unpublished()` JsonRPC functions
- By default, transports are published, but when updating, all existing transports except for the primary one become unpublished in order not to break existing users that followed https://delta.chat/legacy-move
- It is not possible to unpublish the primary transport, and setting a transport as primary automatically sets it to published

An alternative would be to change the existing list_transports API rather than adding a new one list_transports_ex. But to be honest, I don't mind the _ex prefix that much, and I am wary about compatibility issues. But maybe it would be fine; see b08ba4bb8 for how this would look.
2026-03-18 12:14:56 +01:00
Hocuri
c0cc2ae816 refactor: Move transport_tests to their own file 2026-03-18 12:14:56 +01:00
dependabot[bot]
528305e12b chore(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 02:17:21 +00:00
dependabot[bot]
6e0586058d chore(cargo): bump astral-tokio-tar from 0.5.6 to 0.6.0
Bumps [astral-tokio-tar](https://github.com/astral-sh/tokio-tar) from 0.5.6 to 0.6.0.
- [Release notes](https://github.com/astral-sh/tokio-tar/releases)
- [Changelog](https://github.com/astral-sh/tokio-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/tokio-tar/compare/v0.5.6...v0.6.0)

---
updated-dependencies:
- dependency-name: astral-tokio-tar
  dependency-version: 0.6.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-17 22:30:32 +00:00
link2xt
296ed6d74a api!: remove functions for sending and receiving Autocrypt Setup Message 2026-03-17 20:10:59 +00:00
link2xt
8116460f14 feat: enable anonymous OpenPGP key IDs
This was disabled for interoperability in
098084b9a7,
enabling it back now.
2026-03-17 20:08:38 +00:00
link2xt
52f4293bc5 feat: decode dcaccount:// URLs and error out on empty URLs early
The problem was reported at
<https://support.delta.chat/t/could-not-find-dns-resolutions-for-imap-993-when-adding-a-relay/4907>

iOS typically transforms `:` into `://`,
we already handle this in `dclogin` URLs,
so handle it for `dcaccount` as well.
2026-03-17 20:08:24 +00:00
link2xt
cff0192e38 refactor: import tokio_rustls::rustls 2026-03-17 19:10:18 +00:00
link2xt
6f17a86903 refactor: use re-exported rustls::pki_types 2026-03-17 19:10:18 +00:00
dependabot[bot]
4eb77d5a83 chore(deps): bump zizmorcore/zizmor-action from 0.5.0 to 0.5.2
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.0 to 0.5.2.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](0dce2577a4...71321a20a9)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-17 18:00:27 +00:00
link2xt
e06372c954 fix: count recipients by Intended Recipient Fingerprints
Fixes <https://github.com/chatmail/core/issues/7987>
2026-03-17 00:32:33 +00:00
B. Petersen
50cd2514cd test markfresh_chat()
the tests were initially generated by AI and then reworked.
2026-03-16 21:00:32 +01:00
biörn
ba00251572 Update deltachat-ffi/deltachat.h
Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2026-03-16 21:00:32 +01:00
B. Petersen
e690186236 feat: mark messages as "fresh"
this adds an api to make the newest incoming message of a chat as "fresh",
so that UI can offer a "mark chat unread" option as usual for messengers
(eg. swipe right on iOS toggels between "read" and "unread").

"mark unread" is one of the most requested missing features,
used by many ppl to organize their every day messenger usage -
tho "pinning" and "saved messages" are similar,
it seems to be missed often.

we follow a very simple approach here
and just reset the state to `MessageState::InFresh`.
this does not introduce new states or flows.

therefore, chats without any incoming message cannot be marked as fresh.
in practise, this is probably not really an issue,
as the "mark fresh" is usually used to undo a "mark noticed" operation -
and then you have incoming message.
also, most status messages as "all messages are e2ee" count as incoming.

to avoid double sending of MDN,
we remove `Param::WantsMdn` once the MDN is scheduled.
in case MDN are used for syncing, MDN is still sent as before.

many other messenger show a "badge without number",
if we want that as well,
we can always track the "manually set as fresh" state in a parameter.
but for now, it is fine without and showing a "1", which alsso makes sense as badges may be summed up.

there is an iOS pr that uses this new feature,
jsonrpc is left out until api is settled.

also out of scope is synchronisation -
main reason is that "mark noticed" is not synced as well, so we avoid an imbalance here.
both, "mark noticed" as well as "mark fresh" should be synced however,
as soon as this feature is merged.
2026-03-16 21:00:32 +01:00
link2xt
e14151d6cc fix: fsync() the rename() of accounts.toml 2026-03-16 17:09:57 +00:00
link2xt
c6cdccdb97 fix: call sync_all() instead of sync_data() when writing accounts.toml 2026-03-16 17:09:57 +00:00
link2xt
822a99ea9c fix: do not send MDNs for hidden messages
Hidden messages are marked as seen
when chat is marked as noticed.
MDNs to such messages should not be sent
as this notifies the hidden message sender
that the chat was opened.

The issue discovered by Frank Seifferth.
2026-03-15 20:54:50 +00:00
WofWca
bf02785a36 feat: add IncomingCallAccepted.from_this_device 2026-03-14 22:21:46 +04:00
iequidoo
01b2aa0f66 fix: Mark call message as seen when accepting/declining a call (#7842) 2026-03-14 13:46:25 -03:00
iequidoo
fb46c34b55 test: Shift time even more in flaky test_sync_broadcast_and_send_message
As of now, alice1 makes 3 more calls of create_smeared_timestamp() than alice2 does, so we need to
shift time by 3s to fix the test.
2026-03-14 16:20:46 +01:00
link2xt
9393753190 chore: bump version to 2.46.0-dev 2026-03-14 02:58:19 +00:00
117 changed files with 2671 additions and 2537 deletions

View File

@@ -174,7 +174,7 @@ jobs:
run: cargo build -p deltachat_ffi
- name: Upload C library
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-libdeltachat.a
path: target/debug/libdeltachat.a
@@ -200,7 +200,7 @@ jobs:
run: cargo build -p deltachat-rpc-server
- name: Upload deltachat-rpc-server
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-deltachat-rpc-server
path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }}

View File

@@ -40,7 +40,7 @@ jobs:
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux
path: result/bin/deltachat-rpc-server
@@ -64,7 +64,7 @@ jobs:
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
- name: Upload wheel
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
path: result/*.whl
@@ -88,7 +88,7 @@ jobs:
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}
path: result/bin/deltachat-rpc-server.exe
@@ -112,7 +112,7 @@ jobs:
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
- name: Upload wheel
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-wheel
path: result/*.whl
@@ -139,7 +139,7 @@ jobs:
run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-macos
path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server
@@ -163,7 +163,7 @@ jobs:
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android
path: result/bin/deltachat-rpc-server
@@ -187,7 +187,7 @@ jobs:
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
- name: Upload wheel
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android-wheel
path: result/*.whl
@@ -496,7 +496,7 @@ jobs:
ls -lah
- name: Upload to artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-npm-package
path: deltachat-rpc-server/npm-package/*.tgz

View File

@@ -10,7 +10,7 @@ permissions:
jobs:
dependabot:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name
steps:
- name: Dependabot metadata
id: metadata

View File

@@ -23,7 +23,7 @@ jobs:
working-directory: deltachat-rpc-client
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: python-package-distributions
path: deltachat-rpc-client/dist/

View File

@@ -22,7 +22,7 @@ jobs:
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: repl.exe
path: "result/bin/deltachat-repl.exe"

View File

@@ -1,16 +1,18 @@
name: Build & deploy documentation on rs.delta.chat, c.delta.chat, and py.delta.chat
name: Build & deploy documentation on rs.delta.chat, c.delta.chat, py.delta.chat and cffi.delta.chat
on:
push:
branches:
- main
- build_jsonrpc_docs_ci
permissions: {}
jobs:
build-rs:
runs-on: ubuntu-latest
environment:
name: rs.delta.chat
url: https://rs.delta.chat/
steps:
- uses: actions/checkout@v6
@@ -23,12 +25,15 @@ jobs:
- name: Upload to rs.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.KEY }}" > "$HOME/.ssh/key"
echo "${{ secrets.RS_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc "${{ secrets.USERNAME }}@rs.delta.chat:/var/www/html/rs/"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc "${{ secrets.RS_DOCS_SSH_USER }}@rs.delta.chat:/var/www/html/rs.delta.chat/"
build-python:
runs-on: ubuntu-latest
environment:
name: py.delta.chat
url: https://py.delta.chat/
steps:
- uses: actions/checkout@v6
@@ -42,12 +47,15 @@ jobs:
- name: Upload to py.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.CODESPEAK_KEY }}" > "$HOME/.ssh/key"
echo "${{ secrets.PY_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "delta@py.delta.chat:/home/delta/build/master"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "${{ secrets.PY_DOCS_SSH_USER }}@py.delta.chat:/var/www/html/py.delta.chat"
build-c:
runs-on: ubuntu-latest
environment:
name: c.delta.chat
url: https://c.delta.chat/
steps:
- uses: actions/checkout@v6
@@ -61,12 +69,16 @@ jobs:
- name: Upload to c.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.CODESPEAK_KEY }}" > "$HOME/.ssh/key"
echo "${{ secrets.C_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "delta@c.delta.chat:/home/delta/build-c/master"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "${{ secrets.C_DOCS_SSH_USER }}@c.delta.chat:/var/www/html/c.delta.chat"
build-ts:
runs-on: ubuntu-latest
environment:
name: js.jsonrpc.delta.chat
url: https://js.jsonrpc.delta.chat/
defaults:
run:
working-directory: ./deltachat-jsonrpc/typescript
@@ -90,6 +102,27 @@ jobs:
- name: Upload to js.jsonrpc.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.KEY }}" > "$HOME/.ssh/key"
echo "${{ secrets.JS_JSONRPC_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/deltachat-jsonrpc/typescript/docs/ "${{ secrets.USERNAME }}@js.jsonrpc.delta.chat:/var/www/html/js-jsonrpc/"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/deltachat-jsonrpc/typescript/docs/ "${{ secrets.JS_JSONRPC_DOCS_SSH_USER }}@js.jsonrpc.delta.chat:/var/www/html/js.jsonrpc.delta.chat/"
build-cffi:
runs-on: ubuntu-latest
environment:
name: cffi.delta.chat
url: https://cffi.delta.chat/
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Build the documentation with cargo
run: |
cargo doc --package deltachat_ffi --no-deps
- name: Upload to cffi.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.CFFI_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc/ "${{ secrets.CFFI_DOCS_SSH_USER }}@delta.chat:/var/www/html/cffi.delta.chat/"

View File

@@ -1,31 +0,0 @@
# GitHub Actions workflow
# to build `deltachat_ffi` crate documentation
# and upload it to <https://cffi.delta.chat/>
name: Build & Deploy Documentation on cffi.delta.chat
on:
push:
branches:
- main
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Build the documentation with cargo
run: |
cargo doc --package deltachat_ffi --no-deps
- name: Upload to cffi.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc/ "${{ secrets.USERNAME }}@delta.chat:/var/www/html/cffi/"

View File

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

View File

@@ -1,5 +1,136 @@
# Changelog
## [2.48.0] - 2026-03-30
### Fixes
- Fix reordering problems in multi-relay setups by not sorting received messages below the last seen one.
- Always sort "Messages are end-to-end encrypted" notice to the beginning.
- Make Message-ID of pre-messages stable across resends ([#8007](https://github.com/chatmail/core/pull/8007)).
- Delete `imap_markseen` entries not corresponding to any `imap` rows.
- Cleanup `imap` and `imap_sync` records without transport in housekeeping.
- When receiving MDN, mark all preceding messages as noticed, even having same timestamp ([#7928](https://github.com/chatmail/core/pull/7928)).
- Remove migration 108 preventing upgrades from core 1.86.0 to the latest version.
### Features / Changes
- Improve IMAP loop logs.
- Add decryption error to the device message about outgoing message decryption failure.
- Log received message sort timestamp.
### Performance
- Move sorting outside of SQL query in `store_seen_flags_on_imap`.
### API-Changes
- Add JSON-RPC API `markfresh_chat()`.
- ffi: Correctly declare `dc_event_channel_new()` as having no params ([#7831](https://github.com/chatmail/core/pull/7831)).
### Refactor
- Remove `wal_checkpoint_mutex`, lock `write_mutex` before getting sql connection instead.
- Replace async `RwLock` with sync `RwLock` for stock strings.
- Cleanup remaining Autocrypt Setup Message processing in `mimeparser`.
- SecureJoin: do not check for self address in forwarding protection.
- Fix clippy warnings.
### CI
- Update {c,py}.delta.chat website deployments.
- Use environments for {rs,cffi,js.jsonrpc}.delta.chat deployments.
- Fix https://docs.zizmor.sh/audits/#bot-conditions.
### Documentation
- Add SQL performance tips to STYLE.md.
### Tests
- Remove `test_old_message_5`.
- Do not rely on loading newest chat in `load_imf_email()`.
- Use `load_imf_email()` more.
- The message is sorted correctly in the chat even if it arrives late.
### Miscellaneous Tasks
- cargo: update rustls-webpki to 0.103.10.
## [2.47.0] - 2026-03-24
### Fixes
- Don't fall into infinite loop if the folder is missing ([#8021](https://github.com/chatmail/core/pull/8021)).
- Delete `available_post_msgs` row if the message is already downloaded.
- Delete `available_post_msgs` row if there is no corresponding IMAP entry.
- Make newlines work in chat descriptions ([#8012](https://github.com/chatmail/core/pull/8012)).
### Features / Changes
- use SEIPDv2 if all recipients support it.
### Documentation
- Add shadowsocks spec to standards.md.
- Document Header Confidentiality Policy.
- `deltachat_rpc_client`: make sphinx documentation display method parameters.
- Remove `draft/aeap-mvp.md` which is superseded by key-contacts and multi-relay.
### Refactor
- Remove code to send messages without intended recipient fingerprint.
### Tests
- Make `add_or_lookup_contact_id_no_key` public.
### Miscellaneous Tasks
- cargo: bump sdp from 0.10.0 to 0.17.1.
- Add RUSTSEC-2026-0049 exception to deny.toml.
## [2.46.0] - 2026-03-19
### API-Changes
- [**breaking**] remove functions for sending and receiving Autocrypt Setup Message.
- Add `list_transports_ex()` and `set_transport_unpublished()` functions.
- Add API `dc_markfresh_chat` to mark messages as "fresh".
### Features / Changes
- add `IncomingCallAccepted.from_this_device`.
- decode `dcaccount://` URLs and error out on empty URLs early.
- enable anonymous OpenPGP key IDs.
- tls: do not verify TLS certificates for hostnames starting with `_`.
### Fixes
- Mark call message as seen when accepting/declining a call ([#7842](https://github.com/chatmail/core/pull/7842)).
- do not send MDNs for hidden messages.
- call sync_all() instead of sync_data() when writing accounts.toml.
- fsync() the rename() of accounts.toml.
- count recipients by Intended Recipient Fingerprints.
### Miscellaneous Tasks
- deps: bump zizmorcore/zizmor-action from 0.5.0 to 0.5.2.
- cargo: bump astral-tokio-tar from 0.5.6 to 0.6.0.
- deps: bump actions/upload-artifact from 6 to 7.
- cargo: bump blake3 from 1.8.2 to 1.8.3.
- add constant_time_eq 0.3.1 to deny.toml.
### Refactor
- use re-exported rustls::pki_types.
- import tokio_rustls::rustls.
- Move transport_tests to their own file.
### Tests
- Shift time even more in flaky test_sync_broadcast_and_send_message.
- test markfresh_chat()
## [2.45.0] - 2026-03-14
### API-Changes
@@ -7907,3 +8038,6 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.43.0]: https://github.com/chatmail/core/compare/v2.42.0..v2.43.0
[2.44.0]: https://github.com/chatmail/core/compare/v2.43.0..v2.44.0
[2.45.0]: https://github.com/chatmail/core/compare/v2.44.0..v2.45.0
[2.46.0]: https://github.com/chatmail/core/compare/v2.45.0..v2.46.0
[2.47.0]: https://github.com/chatmail/core/compare/v2.46.0..v2.47.0
[2.48.0]: https://github.com/chatmail/core/compare/v2.47.0..v2.48.0

59
Cargo.lock generated
View File

@@ -194,9 +194,9 @@ dependencies = [
[[package]]
name = "astral-tokio-tar"
version = "0.5.6"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5"
checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9"
dependencies = [
"filetime",
"futures-core",
@@ -497,15 +497,16 @@ dependencies = [
[[package]]
name = "blake3"
version = "1.8.2"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0"
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"constant_time_eq 0.4.2",
"cpufeatures",
]
[[package]]
@@ -963,6 +964,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "convert_case"
version = "0.5.0"
@@ -1300,7 +1307,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.45.0"
version = "2.48.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1358,7 +1365,6 @@ dependencies = [
"ratelimit",
"regex",
"rusqlite",
"rustls-pki-types",
"sanitize-filename",
"sdp",
"serde",
@@ -1410,7 +1416,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.45.0"
version = "2.48.0"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1431,7 +1437,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.45.0"
version = "2.48.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1447,7 +1453,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.45.0"
version = "2.48.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1476,7 +1482,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.45.0"
version = "2.48.0"
dependencies = [
"anyhow",
"deltachat",
@@ -2980,7 +2986,7 @@ dependencies = [
"reqwest",
"ring",
"rustls",
"rustls-webpki",
"rustls-webpki 0.102.8",
"serde",
"smallvec",
"spki",
@@ -3026,7 +3032,7 @@ dependencies = [
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"constant_time_eq 0.3.1",
]
[[package]]
@@ -3169,7 +3175,7 @@ dependencies = [
"rand 0.8.5",
"reqwest",
"rustls",
"rustls-webpki",
"rustls-webpki 0.102.8",
"serde",
"sha1",
"strum 0.26.2",
@@ -5150,15 +5156,15 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.23"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
@@ -5193,6 +5199,17 @@ dependencies = [
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.14"
@@ -5301,9 +5318,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdp"
version = "0.10.0"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6"
checksum = "22c3b0257608d7de4de4c4ea650ccc2e6e3e45e3cd80039fcdee768bcb449253"
dependencies = [
"rand 0.9.2",
"substring",
@@ -6165,9 +6182,9 @@ dependencies = [
[[package]]
name = "tokio-rustls"
version = "0.26.2"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.45.0"
version = "2.48.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.88"
@@ -86,9 +86,8 @@ rand-old = { package = "rand", version = "0.8" }
rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rustls-pki-types = "1.12.0"
sanitize-filename = { workspace = true }
sdp = "0.10.0"
sdp = "0.17.1"
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
@@ -104,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.5.6", 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

@@ -68,6 +68,12 @@ keyword doesn't help here.
Consider adding context to `anyhow` errors for SQL statements using `.context()` so that it's
possible to understand from logs which statement failed. See [Errors](#errors) for more info.
When changing complex SQL queries, test them on a new database with `EXPLAIN QUERY PLAN`
to make sure that indexes are used and large tables are not going to be scanned.
Never run `ANALYZE` on the databases,
this makes query planner unpredictable
and may make performance significantly worse: <https://github.com/chatmail/core/issues/6585>
## Errors
Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors.

View File

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

View File

@@ -364,18 +364,14 @@ uint32_t dc_get_id (dc_context_t* context);
* To get these events, you have to create an event emitter using this function
* and call dc_get_next_event() on the emitter.
*
* Events are broadcasted to all existing event emitters.
* Events emitted before creation of event emitter
* are not available to event emitter.
*
* @memberof dc_context_t
* @param context The context object as created by dc_context_new().
* @return Returns the event emitter, NULL on errors.
* Must be freed using dc_event_emitter_unref() after usage.
*
* Note: Use only one event emitter per context.
* The result of having multiple event emitters is unspecified.
* Currently events are broadcasted to all existing event emitters,
* but previous versions delivered events to only one event emitter
* and this behavior may change again in the future.
* Events emitted before creation of event emitter
* may or may not be available to event emitter.
*/
dc_event_emitter_t* dc_get_event_emitter(dc_context_t* context);
@@ -1569,7 +1565,7 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
* (read receipts aren't sent for noticed messages).
*
* Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
* See also dc_markseen_msgs().
* See also dc_markseen_msgs() and dc_markfresh_chat().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
@@ -1578,6 +1574,29 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
void dc_marknoticed_chat (dc_context_t* context, uint32_t chat_id);
/**
* Mark the last incoming message in chat as _fresh_.
*
* UI can use this to offer a "mark unread" option,
* so that already noticed chats (see dc_marknoticed_chat()) get a badge counter again.
*
* dc_get_fresh_msg_cnt() and dc_get_fresh_msgs() usually is increased by one afterwards.
*
* #DC_EVENT_MSGS_CHANGED is fired as usual,
* however, #DC_EVENT_INCOMING_MSG is _not_ fired again.
* This is to not add complexity to incoming messages code,
* e.g. UI usually does not add notifications for manually unread chats.
* If the UI wants to update system badge counters,
* they should do so directly after calling dc_markfresh_chat().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
* @param chat_id The chat ID of which the last incoming message should be marked as fresh.
* If the chat does not have incoming messages, nothing happens.
*/
void dc_markfresh_chat (dc_context_t* context, uint32_t chat_id);
/**
* Returns all message IDs of the given types in a given chat or any chat.
* Typically used to show a gallery.
@@ -2471,76 +2490,6 @@ void dc_imex (dc_context_t* context, int what, c
char* dc_imex_has_backup (dc_context_t* context, const char* dir);
/**
* Initiate Autocrypt Setup Transfer.
* Before starting the setup transfer with this function, the user should be asked:
*
* ~~~
* "An 'Autocrypt Setup Message' securely shares your end-to-end setup with other Autocrypt-compliant apps.
* The setup will be encrypted by a setup code which is displayed here and must be typed on the other device.
* ~~~
*
* After that, this function should be called to send the Autocrypt Setup Message.
* The function creates the setup message and adds it to outgoing message queue.
* The message is sent asynchronously.
*
* The required setup code is returned in the following format:
*
* ~~~
* 1234-1234-1234-1234-1234-1234-1234-1234-1234
* ~~~
*
* The setup code should be shown to the user then:
*
* ~~~
* "Your key has been sent to yourself. Switch to the other device and
* open the setup message. You should be prompted for a setup code. Type
* the following digits into the prompt:
*
* 1234 - 1234 - 1234 -
* 1234 - 1234 - 1234 -
* 1234 - 1234 - 1234
*
* Once you're done, your other device will be ready to use Autocrypt."
* ~~~
*
* On the _other device_ you will call dc_continue_key_transfer() then
* for setup messages identified by dc_msg_is_setupmessage().
*
* For more details about the Autocrypt setup process, please refer to
* https://autocrypt.org/en/latest/level1.html#autocrypt-setup-message
*
* @memberof dc_context_t
* @param context The context object.
* @return The setup code. Must be released using dc_str_unref() after usage.
* On errors, e.g. if the message could not be sent, NULL is returned.
*/
char* dc_initiate_key_transfer (dc_context_t* context);
/**
* Continue the Autocrypt Key Transfer on another device.
*
* If you have started the key transfer on another device using dc_initiate_key_transfer()
* and you've detected a setup message with dc_msg_is_setupmessage(), you should prompt the
* user for the setup code and call this function then.
*
* You can use dc_msg_get_setupcodebegin() to give the user a hint about the code (useful if the user
* has created several messages and should not enter the wrong code).
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id The ID of the setup message to decrypt.
* @param setup_code The setup code entered by the user. This is the same setup code as returned from
* dc_initiate_key_transfer() on the other device.
* There is no need to format the string correctly, the function will remove all spaces and other characters and
* insert the `-` characters at the correct places.
* @return 1=key successfully decrypted and imported; both devices will use the same key now;
* 0=key transfer failed e.g. due to a bad setup code.
*/
int dc_continue_key_transfer (dc_context_t* context, uint32_t msg_id, const char* setup_code);
/**
* Signal an ongoing process to stop.
*
@@ -3370,18 +3319,14 @@ void dc_accounts_set_push_device_token (dc_accounts_t* accounts, const
* This is similar to dc_get_event_emitter(), which, however,
* must not be called for accounts handled by the account manager.
*
* Events are broadcasted to all existing event emitters.
* Events emitted before creation of event emitter
* are not available to event emitter.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
* @return Returns the event emitter, NULL on errors.
* Must be freed using dc_event_emitter_unref() after usage.
*
* Note: Use only one event emitter per account manager.
* The result of having multiple event emitters is unspecified.
* Currently events are broadcasted to all existing event emitters,
* but previous versions delivered events to only one event emitter
* and this behavior may change again in the future.
* Events emitted before creation of event emitter
* are not available to event emitter.
*/
dc_event_emitter_t* dc_accounts_get_event_emitter (dc_accounts_t* accounts);
@@ -4659,7 +4604,10 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#define DC_INFO_GROUP_IMAGE_CHANGED 3
#define DC_INFO_MEMBER_ADDED_TO_GROUP 4
#define DC_INFO_MEMBER_REMOVED_FROM_GROUP 5
// Deprecated as of 2026-03-16, not used for new messages.
#define DC_INFO_AUTOCRYPT_SETUP_MESSAGE 6
#define DC_INFO_SECURE_JOIN_MESSAGE 7
#define DC_INFO_LOCATIONSTREAMING_ENABLED 8
#define DC_INFO_LOCATION_ONLY 9
@@ -4689,40 +4637,6 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
char* dc_msg_get_webxdc_href (const dc_msg_t* msg);
/**
* Check if the message is an Autocrypt Setup Message.
*
* Setup messages should be shown in an unique way e.g. using a different text color.
* On a click or another action, the user should be prompted for the setup code
* which is forwarded to dc_continue_key_transfer() then.
*
* Setup message are typically generated by dc_initiate_key_transfer() on another device.
*
* @memberof dc_msg_t
* @param msg The message object.
* @return 1=message is a setup message, 0=no setup message.
* For setup messages, dc_msg_get_viewtype() returns #DC_MSG_FILE.
*/
int dc_msg_is_setupmessage (const dc_msg_t* msg);
/**
* Get the first characters of the setup code.
*
* Typically, this is used to pre-fill the first entry field of the setup code.
* If the user has several setup messages, he can be sure typing in the correct digits.
*
* To check, if a message is a setup message, use dc_msg_is_setupmessage().
* To decrypt a secret key from a setup message, use dc_continue_key_transfer().
*
* @memberof dc_msg_t
* @param msg The message object.
* @return Typically the first two digits of the setup code or an empty string if unknown.
* NULL is never returned. Must be released using dc_str_unref() when done.
*/
char* dc_msg_get_setupcodebegin (const dc_msg_t* msg);
/**
* Gets the error status of the message.
* If there is no error associated with the message, NULL is returned.
@@ -6037,7 +5951,7 @@ char* dc_jsonrpc_blocking_call(dc_jsonrpc_instance_t* jsonrpc_instance, const ch
* @memberof dc_event_channel_t
* @return An event channel wrapper object (dc_event_channel_t).
*/
dc_event_channel_t* dc_event_channel_new();
dc_event_channel_t* dc_event_channel_new(void);
/**
* Release/free the events channel structure.
@@ -6057,21 +5971,14 @@ void dc_event_channel_unref(dc_event_channel_t* event_channel);
* To get these events, you have to create an event emitter using this function
* and call dc_get_next_event() on the emitter.
*
* This is similar to dc_get_event_emitter(), which, however,
* must not be called for accounts handled by the account manager.
* Events are broadcasted to all existing event emitters.
* Events emitted before creation of event emitter
* are not available to event emitter.
*
* @memberof dc_event_channel_t
* @param The event channel.
* @return Returns the event emitter, NULL on errors.
* Must be freed using dc_event_emitter_unref() after usage.
*
* Note: Use only one event emitter per account manager / event channel.
* The result of having multiple event emitters is unspecified.
* Currently events are broadcasted to all existing event emitters,
* but previous versions delivered events to only one event emitter
* and this behavior may change again in the future.
* Events emitted before creation of event emitter
* are not available to event emitter.
*/
dc_event_emitter_t* dc_event_channel_get_event_emitter(dc_event_channel_t* event_channel);
@@ -6316,7 +6223,7 @@ void dc_event_unref(dc_event_t* event);
* should not be disturbed by a dialog or so. Instead, use a bubble or so.
*
* However, for ongoing processes (e.g. dc_configure())
* or for functions that are expected to fail (e.g. dc_continue_key_transfer())
* or for functions that are expected to fail
* it might be better to delay showing these events until the function has really
* failed (returned false). It should be sufficient to report only the _last_ error
* in a message box then.
@@ -6755,6 +6662,7 @@ void dc_event_unref(dc_event_t* event);
* UI usually only takes action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the message referring to the call
* @param data2 (int) 1 if the call was accepted from this device (process).
*/
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
@@ -6783,8 +6691,8 @@ void dc_event_unref(dc_event_t* event);
* UI should update the list.
*
* The event is emitted when the transports are modified on another device
* using the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`, `delete_transport`
* or `set_config(configured_addr)`.
* using the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`, `delete_transport`,
* `set_transport_unpublished` or `set_config(configured_addr)`.
*/
#define DC_EVENT_TRANSPORTS_MODIFIED 2600

View File

@@ -306,20 +306,17 @@ pub unsafe extern "C" fn dc_set_stock_translation(
let msg = to_string_lossy(stock_msg);
let ctx = &*context;
block_on(async move {
match StockMessage::from_u32(stock_id)
.with_context(|| format!("Invalid stock message ID {stock_id}"))
match StockMessage::from_u32(stock_id)
.with_context(|| format!("Invalid stock message ID {stock_id}"))
.log_err(ctx)
{
Ok(id) => ctx
.set_stock_translation(id, msg)
.context("set_stock_translation failed")
.log_err(ctx)
{
Ok(id) => ctx
.set_stock_translation(id, msg)
.await
.context("set_stock_translation failed")
.log_err(ctx)
.is_ok() as libc::c_int,
Err(_) => 0,
}
})
.is_ok() as libc::c_int,
Err(_) => 0,
}
}
#[no_mangle]
@@ -680,7 +677,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ChatModified(_)
| EventType::ChatDeleted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::IncomingCallAccepted { .. }
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. }
@@ -703,6 +699,9 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
} => status_update_serial.to_u32() as libc::c_int,
EventType::WebxdcRealtimeData { data, .. } => data.len() as libc::c_int,
EventType::IncomingCall { has_video, .. } => *has_video as libc::c_int,
EventType::IncomingCallAccepted {
from_this_device, ..
} => *from_this_device as libc::c_int,
#[allow(unreachable_patterns)]
#[cfg(test)]
@@ -1521,6 +1520,23 @@ pub unsafe extern "C" fn dc_marknoticed_chat(context: *mut dc_context_t, chat_id
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_markfresh_chat(context: *mut dc_context_t, chat_id: u32) {
if context.is_null() {
eprintln!("ignoring careless call to dc_markfresh_chat()");
return;
}
let ctx = &*context;
block_on(async move {
chat::markfresh_chat(ctx, ChatId::new(chat_id))
.await
.context("Failed markfresh chat")
.log_err(ctx)
.unwrap_or(())
})
}
fn from_prim<S, T>(s: S) -> Option<T>
where
T: FromPrimitive,
@@ -2427,45 +2443,6 @@ pub unsafe extern "C" fn dc_imex_has_backup(
}
}
#[no_mangle]
pub unsafe extern "C" fn dc_initiate_key_transfer(context: *mut dc_context_t) -> *mut libc::c_char {
if context.is_null() {
eprintln!("ignoring careless call to dc_initiate_key_transfer()");
return ptr::null_mut(); // NULL explicitly defined as "error"
}
let ctx = &*context;
match block_on(imex::initiate_key_transfer(ctx))
.context("dc_initiate_key_transfer()")
.log_err(ctx)
{
Ok(res) => res.strdup(),
Err(_) => ptr::null_mut(),
}
}
#[no_mangle]
pub unsafe extern "C" fn dc_continue_key_transfer(
context: *mut dc_context_t,
msg_id: u32,
setup_code: *const libc::c_char,
) -> libc::c_int {
if context.is_null() || msg_id <= constants::DC_MSG_ID_LAST_SPECIAL || setup_code.is_null() {
eprintln!("ignoring careless call to dc_continue_key_transfer()");
return 0;
}
let ctx = &*context;
block_on(imex::continue_key_transfer(
ctx,
MsgId::new(msg_id),
&to_string_lossy(setup_code),
))
.context("dc_continue_key_transfer")
.log_err(ctx)
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_stop_ongoing_process(context: *mut dc_context_t) {
if context.is_null() {
@@ -3788,16 +3765,6 @@ pub unsafe extern "C" fn dc_msg_get_webxdc_href(msg: *mut dc_msg_t) -> *mut libc
ffi_msg.message.get_webxdc_href().strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_is_setupmessage(msg: *mut dc_msg_t) -> libc::c_int {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_is_setupmessage()");
return 0;
}
let ffi_msg = &*msg;
ffi_msg.message.is_setupmessage().into()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int {
if msg.is_null() {
@@ -3808,20 +3775,6 @@ pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int {
ffi_msg.message.has_html().into()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_setupcodebegin(msg: *mut dc_msg_t) -> *mut libc::c_char {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_setupcodebegin()");
return "".strdup();
}
let ffi_msg = &*msg;
let ctx = &*ffi_msg.context;
block_on(ffi_msg.message.get_setupcodebegin(ctx))
.unwrap_or_default()
.strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_set_text(msg: *mut dc_msg_t, text: *const libc::c_char) {
if msg.is_null() {

View File

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

View File

@@ -11,8 +11,8 @@ use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers;
use deltachat::chat::{
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
get_chat_msgs_ex, marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, Chat,
ChatId, ChatItem, MessageListOptions,
get_chat_msgs_ex, markfresh_chat, marknoticed_all_chats, marknoticed_chat,
remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
};
use deltachat::chatlist::Chatlist;
use deltachat::config::{get_all_ui_config_keys, Config};
@@ -68,6 +68,7 @@ use self::types::{
},
};
use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult};
use crate::api::types::login_param::TransportListEntry;
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
#[derive(Debug)]
@@ -472,9 +473,7 @@ impl CommandApi {
let accounts = self.accounts.read().await;
for (stock_id, stock_message) in strings {
if let Some(stock_id) = StockMessage::from_u32(stock_id) {
accounts
.set_stock_translation(stock_id, stock_message)
.await?;
accounts.set_stock_translation(stock_id, stock_message)?;
}
}
Ok(())
@@ -528,6 +527,7 @@ impl CommandApi {
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
async fn add_or_update_transport(
&self,
account_id: u32,
@@ -553,7 +553,23 @@ impl CommandApi {
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
/// Use [Self::list_transports_ex()] to additionally query
/// whether the transports are marked as 'unpublished'.
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
.list_transports()
.await?
.into_iter()
.map(|t| t.param.into())
.collect();
Ok(res)
}
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
async fn list_transports_ex(&self, account_id: u32) -> Result<Vec<TransportListEntry>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
.list_transports()
@@ -571,6 +587,26 @@ impl CommandApi {
ctx.delete_transport(&addr).await
}
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// 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.
///
/// The default is false, but when the user updates from a version that didn't have this flag,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
async fn set_transport_unpublished(
&self,
account_id: u32,
addr: String,
unpublished: bool,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.set_transport_unpublished(&addr, unpublished).await
}
/// Signal an ongoing process to stop.
async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
@@ -699,25 +735,6 @@ impl CommandApi {
message::estimate_deletion_cnt(&ctx, from_server, seconds).await
}
// ---------------------------------------------
// autocrypt
// ---------------------------------------------
async fn initiate_autocrypt_key_transfer(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
deltachat::imex::initiate_key_transfer(&ctx).await
}
async fn continue_autocrypt_key_transfer(
&self,
account_id: u32,
message_id: u32,
setup_code: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
deltachat::imex::continue_key_transfer(&ctx, MsgId::new(message_id), &setup_code).await
}
// ---------------------------------------------
// chat list
// ---------------------------------------------
@@ -1232,6 +1249,15 @@ impl CommandApi {
marknoticed_chat(&ctx, ChatId::new(chat_id)).await
}
/// Marks the last incoming message in the chat as _fresh_.
///
/// UI can use this to offer a "mark unread" option,
/// so that already noticed chats get a badge counter again.
async fn markfresh_chat(&self, account_id: u32, chat_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
markfresh_chat(&ctx, ChatId::new(chat_id)).await
}
/// Returns the message that is immediately followed by the last seen
/// message.
/// From the point of view of the user this is effectively

View File

@@ -441,6 +441,8 @@ pub enum EventType {
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.
@@ -634,9 +636,14 @@ impl From<CoreEventType> for EventType {
place_call_info,
has_video,
},
CoreEventType::IncomingCallAccepted { msg_id, chat_id } => IncomingCallAccepted {
CoreEventType::IncomingCallAccepted {
msg_id,
chat_id,
from_this_device,
} => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
from_this_device,
},
CoreEventType::OutgoingCallAccepted {
msg_id,

View File

@@ -4,6 +4,16 @@ use serde::Deserialize;
use serde::Serialize;
use yerpc::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TransportListEntry {
/// The login data entered by the user.
pub param: EnteredLoginParam,
/// Whether this transport is set to 'unpublished'.
/// See `set_transport_unpublished` / `setTransportUnpublished` for details.
pub is_unpublished: bool,
}
/// Login parameters entered by the user.
///
/// Usually it will be enough to only set `addr` and `password`,
@@ -56,6 +66,15 @@ pub struct EnteredLoginParam {
pub oauth2: Option<bool>,
}
impl From<dc::TransportListEntry> for TransportListEntry {
fn from(transport: dc::TransportListEntry) -> Self {
TransportListEntry {
param: transport.param.into(),
is_unpublished: transport.is_unpublished,
}
}
}
impl From<dc::EnteredLoginParam> for EnteredLoginParam {
fn from(param: dc::EnteredLoginParam) -> Self {
let imap_security: Socket = param.imap.security.into();

View File

@@ -68,7 +68,6 @@ pub struct MessageObject {
/// if `show_padlock` is `false`,
/// and nothing if it is `true`.
show_padlock: bool,
is_setupmessage: bool,
is_info: bool,
is_forwarded: bool,
@@ -88,8 +87,6 @@ pub struct MessageObject {
override_sender_name: Option<String>,
sender: ContactObject,
setup_code_begin: Option<String>,
file: Option<String>,
file_mime: Option<String>,
@@ -226,7 +223,6 @@ impl MessageObject {
subject: message.get_subject().to_owned(),
show_padlock: message.get_showpadlock(),
is_setupmessage: message.is_setupmessage(),
is_info: message.is_info(),
is_forwarded: message.is_forwarded(),
is_bot: message.is_bot(),
@@ -243,8 +239,6 @@ impl MessageObject {
override_sender_name,
sender,
setup_code_begin: message.get_setupcodebegin(context).await,
file: match message.get_file(context) {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
None => None,

View File

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

View File

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

View File

@@ -302,9 +302,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
// TODO: reuse commands definition in main.rs.
"imex" => println!(
"====================Import/Export commands==\n\
initiate-key-transfer\n\
get-setupcodebegin <msg-id>\n\
continue-key-transfer <msg-id> <setup-code>\n\
has-backup\n\
export-backup\n\
import-backup <backup-file>\n\
@@ -408,34 +405,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
============================================="
),
},
"initiate-key-transfer" => match initiate_key_transfer(&context).await {
Ok(setup_code) => {
println!("Setup code for the transferred setup message: {setup_code}",)
}
Err(err) => bail!("Failed to generate setup code: {err}"),
},
"get-setupcodebegin" => {
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
let msg_id: MsgId = MsgId::new(arg1.parse()?);
let msg = Message::load_from_db(&context, msg_id).await?;
if msg.is_setupmessage() {
let setupcodebegin = msg.get_setupcodebegin(&context).await;
println!(
"The setup code for setup message {} starts with: {}",
msg_id,
setupcodebegin.unwrap_or_default(),
);
} else {
bail!("{msg_id} is no setup message.",);
}
}
"continue-key-transfer" => {
ensure!(
!arg1.is_empty() && !arg2.is_empty(),
"Arguments <msg-id> <setup-code> expected"
);
continue_key_transfer(&context, MsgId::new(arg1.parse()?), arg2).await?;
}
"has-backup" => {
has_backup(&context, blobdir).await?;
}

View File

@@ -149,10 +149,7 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 13] = [
"initiate-key-transfer",
"get-setupcodebegin",
"continue-key-transfer",
const IMEX_COMMANDS: [&str; 10] = [
"has-backup",
"export-backup",
"import-backup",

View File

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

View File

@@ -187,13 +187,9 @@ class futuremethod: # noqa: N801
"""Decorator for async methods."""
def __init__(self, func):
functools.update_wrapper(self, func)
self._func = func
def __get__(self, instance, owner=None):
if instance is None:
return self
def future(*args):
generator = self._func(instance, *args)
res = next(generator)
@@ -206,6 +202,7 @@ class futuremethod: # noqa: N801
return f
@functools.wraps(self._func)
def wrapper(*args):
f = future(*args)
return f()

View File

@@ -483,10 +483,6 @@ class Account:
passphrase = "" # Importing passphrase-protected keys is currently not supported.
self._rpc.import_self_keys(self.id, str(path), passphrase)
def initiate_autocrypt_key_transfer(self) -> None:
"""Send Autocrypt Setup Message."""
return self._rpc.initiate_autocrypt_key_transfer(self.id)
def ice_servers(self) -> list:
"""Return ICE servers for WebRTC configuration."""
ice_servers_json = self._rpc.ice_servers(self.id)

View File

@@ -219,6 +219,10 @@ class Chat:
"""Mark all messages in this chat as noticed."""
self._rpc.marknoticed_chat(self.account.id, self.id)
def mark_fresh(self) -> None:
"""Mark the last incoming message in the chat as fresh."""
self._rpc.markfresh_chat(self.account.id, self.id)
def add_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
"""Add contacts to this group."""
from .account import Account

View File

@@ -72,14 +72,6 @@ class Message:
"""Return True if the message exists."""
return bool(self._rpc.get_existing_msg_ids(self.account.id, [self.id]))
def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
"""Continue the Autocrypt Setup Message key transfer.
This function can be called on received Autocrypt Setup Message
to import the key encrypted with the provided setup code.
"""
self._rpc.continue_autocrypt_key_transfer(self.account.id, self.id, setup_code)
def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None:
"""Send a webxdc status update. This message must be a webxdc."""
if not isinstance(update, str):

View File

@@ -1,49 +0,0 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.rpc import JsonRpcError
def wait_for_autocrypt_setup_message(account):
while True:
event = account.wait_for_event()
if event.kind == EventType.MSGS_CHANGED and event.msg_id != 0:
msg_id = event.msg_id
msg = account.get_message_by_id(msg_id)
if msg.get_snapshot().is_setupmessage:
return msg
def test_autocrypt_setup_message_key_transfer(acfactory):
alice1 = acfactory.get_online_account()
alice2 = acfactory.get_unconfigured_account()
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
alice2.bring_online()
setup_code = alice1.initiate_autocrypt_key_transfer()
msg = wait_for_autocrypt_setup_message(alice2)
# Test that entering wrong code returns an error.
with pytest.raises(JsonRpcError):
msg.continue_autocrypt_key_transfer("7037-0673-6287-3013-4095-7956-5617-6806-6756")
msg.continue_autocrypt_key_transfer(setup_code)
def test_ac_setup_message_twice(acfactory):
alice1 = acfactory.get_online_account()
alice2 = acfactory.get_unconfigured_account()
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
alice2.bring_online()
# Send the first Autocrypt Setup Message and ignore it.
_setup_code = alice1.initiate_autocrypt_key_transfer()
wait_for_autocrypt_setup_message(alice2)
# Send the second Autocrypt Setup Message and import it.
setup_code = alice1.initiate_autocrypt_key_transfer()
msg = wait_for_autocrypt_setup_message(alice2)
msg.continue_autocrypt_key_transfer(setup_code)

View File

@@ -1,7 +1,7 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import DownloadState
from deltachat_rpc_client.const import ChatType, DownloadState
from deltachat_rpc_client.rpc import JsonRpcError
@@ -357,7 +357,7 @@ def test_message_info_imap_urls(acfactory) -> None:
assert f"{new_alice_addr}/INBOX" in msg_info
def test_remove_primary_transport(acfactory) -> None:
def test_remove_primary_transport(acfactory, log) -> None:
"""Test that after removing the primary relay, Alice can still receive messages."""
alice, bob = acfactory.get_online_accounts(2)
qr = acfactory.get_account_qr()
@@ -368,7 +368,7 @@ def test_remove_primary_transport(acfactory) -> None:
bob_chat = bob.create_chat(alice)
alice.create_chat(bob)
# Alice changes the transport.
log.section("Alice sets up second transport")
[transport1, transport2] = alice.list_transports()
alice.set_config("configured_addr", transport2["addr"])
@@ -376,7 +376,7 @@ def test_remove_primary_transport(acfactory) -> None:
msg1 = alice.wait_for_incoming_msg().get_snapshot()
assert msg1.text == "Hello!"
# Alice deletes the first transport.
log.section("Alice removes the primary relay")
alice.delete_transport(transport1["addr"])
alice.stop_io()
alice.start_io()
@@ -384,3 +384,5 @@ def test_remove_primary_transport(acfactory) -> None:
bob_chat.send_text("Hello again!")
msg2 = alice.wait_for_incoming_msg().get_snapshot()
assert msg2.text == "Hello again!"
assert msg2.chat.get_basic_snapshot().chat_type == ChatType.SINGLE
assert msg2.chat == alice.create_chat(bob)

View File

@@ -273,6 +273,9 @@ def test_chat(acfactory) -> None:
assert group.get_messages()
group.get_fresh_message_count()
group.mark_noticed()
assert group.get_fresh_message_count() == 0
group.mark_fresh()
assert group.get_fresh_message_count() > 0
assert group.get_contacts()
assert group.get_past_contacts() == []
group.remove_contact(alice_contact_bob)

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.45.0"
version = "2.48.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.45.0"
"version": "2.48.0"
}

View File

@@ -17,6 +17,13 @@ ignore = [
# It is a transitive dependency of iroh 0.35.0,
# this should be fixed by upgrading to iroh 1.0 once it is released.
"RUSTSEC-2025-0134",
# rustls-webpki v0.102.8
# We cannot upgrade to >=0.103.10 because
# it is a transitive dependency of iroh 0.35.0
# which depends on ^0.102.
# <https://rustsec.org/advisories/RUSTSEC-2026-0049>
"RUSTSEC-2026-0049",
]
[bans]
@@ -27,6 +34,7 @@ ignore = [
skip = [
{ name = "async-channel", version = "1.9.0" },
{ name = "bitflags", version = "1.3.2" },
{ name = "constant_time_eq", version = "0.3.1" },
{ name = "derive_more-impl", version = "1.0.0" },
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },
@@ -41,6 +49,7 @@ skip = [
{ name = "rand_core", version = "0.6.4" },
{ name = "rand", version = "0.8.5" },
{ name = "rustix", version = "0.38.44" },
{ name = "rustls-webpki", version = "0.102.8" },
{ name = "serdect", version = "0.2.0" },
{ name = "socket2", version = "0.5.9" },
{ name = "spin", version = "0.9.8" },

View File

@@ -1,127 +0,0 @@
AEAP MVP
========
Changes to the UIs
------------------
- The secondary self addresses (see below) are shown in the UI, but not editable.
- When the user changed the email address in the configure screen, show a dialog to the user, either directly explaining things or with a link to the FAQ (see "Other" below)
Changes in the core
-------------------
- [x] We have one primary self address and any number of secondary self addresses. `is_self_addr()` checks all of them.
- [x] If the user does a reconfigure and changes the email address, the previous address is added as a secondary self address.
- don't forget to deduplicate secondary self addresses in case the user switches back and forth between addresses).
- The key stays the same.
- [x] No changes for 1:1 chats, there simply is a new one. (This works since, contrary to group messages, messages sent to a 1:1 chat are not assigned to the group chat but always to the 1:1 chat with the sender. So it's not a problem that the new messages might be put into the old chat if they are a reply to a message there.)
- [ ] When sending a message: If any of the secondary self addrs is in the chat's member list, remove it locally (because we just transitioned away from it). We add a log message for this (alternatively, a system message in the chat would be more visible).
- [x] ([#3385](https://github.com/deltachat/deltachat-core-rust/pull/3385)) When receiving a message: If the key exists, but belongs to another address (we may want to benchmark this)
AND there is a `Chat-Version` header\
AND the message is signed correctly
AND the From address is (also) in the encrypted (and therefore signed) headers <sup>[[1]](#myfootnote1)</sup>\
AND the message timestamp is newer than the contact's `lastseen` (to prevent changing the address back when messages arrive out of order) (this condition is not that important since we will have eventual consistency even without it):
Replace the contact in _all_ groups, possibly deduplicate the members list, and add a system message to all of these chats.
- Note that we can't simply compare the keys byte-by-byte, since the UID may have changed, or the sender may have rotated the key and signed the new key with the old one.
<a name="myfootnote1">[1]</a>: Without this check, an attacker could replay a message from Alice to Bob. Then Bob's device would do an AEAP transition from Alice's to the attacker's address, allowing for easier phishing.
<details>
<summary>More details about this</summary>
Suppose Alice sends a message to Evil (or to a group with both Evil and Bob). Evil then forwards the message to Bob, changing the From and To headers (and if necessary Message-Id) and replacing `addr=alice@example.org;` in the autocrypt header with `addr=evil@example.org;`.
Then Bob's device sees that there is a message which is signed by Alice's key and comes from Evil's address and would do the AEAP transition, i.e. replace Alice with Evil in all groups and show a message "Alice changed their address from alice@example.org to evil@example.org". Disadvantages for Evil are that Bob's message will be shown on Alice's device, possibly creating confusion/suspicion, and that the usual "Setup changed for..." message will be shown the next time Evil sends a message (because Evil doesn't know Alice's private key).
Possible mitigations:
- if we make the AEAP device message sth. like "Automatically removed alice@example.org and added evil@example.org", then this will create more suspicion, making the phishing harder (we didn't talk about what what the wording should be at all yet).
- Add something similar to replay protection to our Autocrypt implementation. This could be done e.g. by adding a second `From` header to the protected headers. If it's present, the receiver then requires it to be the same as the outer `From`, and if it's not present, we don't do AEAP --> **That's what we implemented**
Note that usually a mail is signed by a key that has a UID matching the from address.
That's not mandatory for Autocrypt (and in fact, we just keep the old UID when changing the self address, so with AEAP the UID will actually be different than the from address sometimes)
https://autocrypt.org/level1.html#openpgp-based-key-data says:
> The content of the user id packet is only decorative
</details>
### Notes:
- We treat protected and non-protected chats the same
- We leave the aeap transition statement away since it seems not to be needed, makes things harder on the sending side, wastes some network traffic, and is worse for privacy (since more people know what old addresses you had).
- As soon as we encrypt read receipts, sending a read receipt will be enough to tell a lot of people that you transitioned
- AEAP will make the problem of inconsistent group state worse, both because it doesn't work if the message is unencrypted (even if the design allowed it, it would be problematic security-wise) and because some chat partners may have gotten the transition and some not. We should do something against this at some point in the future, like asking the user whether they want to add/remove the members to restore consistent group state.
#### Downsides of this design:
- Inconsistent group state: Suppose Alice does an AEAP transition and sends a 1:1 message to Bob, so Bob rewrites Alice's contact. Alice, Bob and Charlie are together in a group. Before Alice writes to this group, Bob and Charlie will have different membership lists, and Bob will send messages to Alice's new address, while Charlie will send them to her old address.
#### Upsides:
- With this approach, it's easy to switch to a model where the info about the transition is encoded in the PGP key. Since the key is gossiped, the information about the transition will spread virally.
- Faster transition: If you send a message to e.g. "Delta Chat Dev", all members of the "sub-group" "delta android" will know of your transition.
### Alternatives and old discussions/plans:
- Change the contact instead of rewriting the group member lists. This seems to call for more trouble since we will end up with multiple contacts having the same email address.
- If needed, we could add a header a) indicating that the sender did an address transition or b) listing all the secondary (old) addresses. For now, there is no big enough benefit to warrant introducing another header and its processing on the receiver side (including all the necessary checks and handling of error cases). Instead, we only check for the `Chat-Version` header to prevent accidental transitions when an MUA user sends a message from another email address with the same key.
- The condition for a transition temporarily was:
> When receiving a message: If we are going to assign a message to a chat, but the sender is not a member of this chat\
> AND the signing key is the same as the direct (non-gossiped) key of one of the chat members\
> AND ...
However, this would mean that in 1:1 messages can't trigger a transition, since we don't assign private messages to the parent chat, but always to the 1:1 chat with the sender.
<details>
<summary>Some previous state of the discussion, which temporarily lived in an issue description</summary>
Summarizing the discussions from https://github.com/deltachat/deltachat-core-rust/pull/2896, mostly quoting @hpk42:
1. (DONE) At the time of configure we push the current primary to become a secondary.
2. When a message is sent out to a chat, and the message is encrypted, and we have secondary addresses, then we
a) add a protected "AEAP-Replacement" header that contains all secondary addresses
b) if any of the secondary addresses is in the chat's member list, we remove it and leave a system message that we did so
3. When an encrypted message with a replacement header is received, replace the e-mail address of all secondary contacts (if they exist) with the new primary and drop a sysmessage in all chats the secondary is member off. This might (in edge cases) result in chats that have two or more contacts with the same e-mail address. We might ignore this for a first release and just log a warning. Let's maybe not get hung up on this case before everything else works.
Notes:
- for now we will send out aeap replacement headers forever, there is no termination condition other than lack of secondary addresses. I think that's fine for now. Later on we might introduce options to remove secondary addresses but i wouldn't do this for a first release/PR.
- the design is resilient against changing e-mail providers from A to B to C and then back to A, with partially updated chats and diverging views from recipients/contacts on this transition. In the end, you will have a primary and some secondaries, and when you start sending out messages everybody will eventually synchronize when they receive the current state of primaries/secondaries.
- of course on incoming message for need to check for each stated secondary address in the replacement header that it uses the same signature as the signature we verified as valid with the incoming message **--> Also we have to somehow make sure that the signing key was not just gossiped from some random other person in some group.**
- there are no extra flags/columns in the database needed (i hope)
#### Downsides of the chosen approach:
- Inconsistent group state: Suppose Alice does an AEAP transition and sends a 1:1 message to Bob, so Bob rewrites Alice's contact. Alice, Bob and Charlie are together in a group. Before Alice writes to this group, Bob and Charlie will have different membership lists, and Bob will send messages to Alice's new address, while Charlie will send them to her old address.
- There will be multiple contacts with the same address in the database. We will have to do something against this at some point.
The most obvious alternative would be to create a new contact with the new address and replace the old contact in the groups.
#### Upsides:
- With this approach, it's easier to switch to a model where the info about the transition is encoded in the PGP key. Since the key is gossiped, the information about the transition will spread virally.
- (Also, less important: Slightly faster transition: If you send a message to e.g. "Delta Chat Dev", all members of the "sub-group" "delta android" will know of your transition.)
- It's easier to implement (if too many problems turn up, we can still switch to another approach and didn't waste that much development time.)
[full messages](https://github.com/deltachat/deltachat-core-rust/pull/2896#discussion_r852002161)
_end of the previous state of the discussion_
</details>
Other
-----
- The user is responsible that messages to the old address arrive at the new address, for example by configuring the old provider to forward all emails to the new one.
Notes during implementing
========================
- As far as I understand the code, unencrypted messages are unsigned. So, the transition only works if both sides have the other side's key.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.45.0"
version = "2.48.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

@@ -553,17 +553,6 @@ class Account:
def imex(self, path: str, imex_cmd: int, passphrase: Optional[str] = None) -> None:
lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), as_dc_charpointer(passphrase))
def initiate_key_transfer(self) -> str:
"""return setup code after a Autocrypt setup message
has been successfully sent to our own e-mail address ("self-sent message").
If sending out was unsuccessful, a RuntimeError is raised.
"""
self.check_is_configured()
res = lib.dc_initiate_key_transfer(self._dc_context)
if res == ffi.NULL:
raise RuntimeError("could not send out autocrypt setup message")
return from_dc_charpointer(res)
def get_setup_contact_qr(self) -> str:
"""get/create Setup-Contact QR Code as ascii-string.

View File

@@ -167,14 +167,6 @@ class Message:
"""return True if this message is a system/info message."""
return bool(lib.dc_msg_is_info(self._dc_msg))
def is_setup_message(self):
"""return True if this message is a setup message."""
return lib.dc_msg_is_setupmessage(self._dc_msg)
def get_setupcodebegin(self) -> str:
"""return the first characters of a setup code in a setup message."""
return from_dc_charpointer(lib.dc_msg_get_setupcodebegin(self._dc_msg))
def is_encrypted(self):
"""return True if this message was encrypted."""
return bool(lib.dc_msg_get_showpadlock(self._dc_msg))
@@ -198,12 +190,6 @@ class Message:
"""Get a message summary as a single line of text. Typically used for notifications."""
return from_dc_charpointer(lib.dc_msg_get_summarytext(self._dc_msg, width))
def continue_key_transfer(self, setup_code):
"""extract key and use it as primary key for this account."""
res = lib.dc_continue_key_transfer(self.account._dc_context, self.id, as_dc_charpointer(setup_code))
if res == 0:
raise ValueError("Importing the key from Autocrypt Setup Message failed")
@props.with_doc
def time_sent(self):
"""UTC time when the message was sent.

View File

@@ -1 +1 @@
2026-03-14
2026-03-30

21
spec.md
View File

@@ -39,9 +39,24 @@ Messages SHOULD be encrypted by the
[Autocrypt](https://autocrypt.org/level1.html) standard;
`prefer-encrypt=mutual` MAY be set by default.
Meta data (at least the subject and all chat-headers) SHOULD be encrypted
by the [Protected Headers](https://tools.ietf.org/id/draft-autocrypt-lamps-protected-headers-02.html) standard.
Meta data SHOULD be encrypted
by the [Header Protection](https://www.rfc-editor.org/rfc/rfc9788.html) standard
with the following [Header Confidentiality Policy](https://www.rfc-editor.org/rfc/rfc9788.html#name-header-confidentiality-poli):
```
hcp_chat(name, val_in) → val_out:
if lower(name) is 'from':
assert that val_in is an RFC 5322 mailbox
return the RFC 5322 addr-spec part of val_in
else if lower(name) is 'to':
return '"hidden-recipients": ;'
else if lower(name) is 'date':
return the UTC form of a random date within the last 7 days
else if lower(name) is 'subject':
return '[...]'
else if lower(name) is in ['message-id', 'chat-is-post-message']:
return val_in
return null
```
# Outgoing messages

View File

@@ -686,13 +686,27 @@ impl Config {
file.write_all(toml::to_string_pretty(&self.inner)?.as_bytes())
.await
.context("failed to write a tmp config")?;
file.sync_data()
// We use `sync_all()` and not `sync_data()` here.
// This translates to `fsync()` instead of `fdatasync()`.
// `fdatasync()` may be insufficient for newely created files
// and may not even synchronize the file size on some operating systems,
// resulting in a truncated file.
file.sync_all()
.await
.context("failed to sync a tmp config")?;
drop(file);
fs::rename(&tmp_path, &self.file)
.await
.context("failed to rename config")?;
// Sync the rename().
#[cfg(not(windows))]
{
let parent = self.file.parent().context("No parent directory")?;
let parent_file = fs::File::open(parent).await?;
parent_file.sync_all().await?;
}
Ok(())
}
@@ -1235,13 +1249,11 @@ mod tests {
let account1 = accounts.get_account(1).context("failed to get account 1")?;
let account2 = accounts.get_account(2).context("failed to get account 2")?;
assert_eq!(stock_str::no_messages(&account1).await, "No messages.");
assert_eq!(stock_str::no_messages(&account2).await, "No messages.");
account1
.set_stock_translation(StockMessage::NoMessages, "foobar".to_string())
.await?;
assert_eq!(stock_str::no_messages(&account1).await, "foobar");
assert_eq!(stock_str::no_messages(&account2).await, "foobar");
assert_eq!(stock_str::no_messages(&account1), "No messages.");
assert_eq!(stock_str::no_messages(&account2), "No messages.");
account1.set_stock_translation(StockMessage::NoMessages, "foobar".to_string())?;
assert_eq!(stock_str::no_messages(&account1), "foobar");
assert_eq!(stock_str::no_messages(&account2), "foobar");
Ok(())
}

View File

@@ -11,7 +11,7 @@ use crate::context::{Context, WeakContext};
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype};
use crate::message::{Message, MsgId, Viewtype, markseen_msgs};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
@@ -104,13 +104,11 @@ impl CallInfo {
};
if self.is_incoming() {
let incoming_call_str =
stock_str::incoming_call(context, self.has_video_initially()).await;
let incoming_call_str = stock_str::incoming_call(context, self.has_video_initially());
self.update_text(context, &format!("{incoming_call_str}\n{duration}"))
.await?;
} else {
let outgoing_call_str =
stock_str::outgoing_call(context, self.has_video_initially()).await;
let outgoing_call_str = stock_str::outgoing_call(context, self.has_video_initially());
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
.await?;
}
@@ -207,7 +205,7 @@ impl Context {
);
ensure!(!chat.is_self_talk(), "Cannot call self");
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially).await;
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially);
let mut call = Message {
viewtype: Viewtype::Call,
text: outgoing_call_str,
@@ -249,6 +247,7 @@ impl Context {
if chat.is_contact_request() {
chat.id.accept(self).await?;
}
markseen_msgs(self, vec![call_id]).await?;
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
@@ -265,6 +264,7 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: true,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
@@ -283,11 +283,12 @@ impl Context {
if !call.is_accepted() {
if call.is_incoming() {
call.mark_as_ended(self).await?;
let declined_call_str = stock_str::declined_call(self).await;
markseen_msgs(self, vec![call_id]).await?;
let declined_call_str = stock_str::declined_call(self);
call.update_text(self, &declined_call_str).await?;
} else {
call.mark_as_canceled(self).await?;
let canceled_call_str = stock_str::canceled_call(self).await;
let canceled_call_str = stock_str::canceled_call(self);
call.update_text(self, &canceled_call_str).await?;
}
} else {
@@ -330,11 +331,11 @@ impl Context {
if !call.is_accepted() && !call.is_ended() {
if call.is_incoming() {
call.mark_as_canceled(&context).await?;
let missed_call_str = stock_str::missed_call(&context).await;
let missed_call_str = stock_str::missed_call(&context);
call.update_text(&context, &missed_call_str).await?;
} else {
call.mark_as_ended(&context).await?;
let canceled_call_str = stock_str::canceled_call(&context).await;
let canceled_call_str = stock_str::canceled_call(&context);
call.update_text(&context, &canceled_call_str).await?;
}
context.emit_msgs_changed(call.msg.chat_id, call_id);
@@ -360,12 +361,12 @@ impl Context {
if call.is_incoming() {
if call.is_stale() {
let missed_call_str = stock_str::missed_call(self).await;
let missed_call_str = stock_str::missed_call(self);
call.update_text(self, &missed_call_str).await?;
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
} else {
let incoming_call_str =
stock_str::incoming_call(self, call.has_video_initially()).await;
stock_str::incoming_call(self, call.has_video_initially());
call.update_text(self, &incoming_call_str).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
let can_call_me = match who_can_call_me(self).await? {
@@ -406,8 +407,7 @@ impl Context {
));
}
} else {
let outgoing_call_str =
stock_str::outgoing_call(self, call.has_video_initially()).await;
let outgoing_call_str = stock_str::outgoing_call(self, call.has_video_initially());
call.update_text(self, &outgoing_call_str).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
}
@@ -430,6 +430,7 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: false,
});
} else {
let accept_call_info = mime_message
@@ -458,22 +459,22 @@ impl Context {
if call.is_incoming() {
if from_id == ContactId::SELF {
call.mark_as_ended(self).await?;
let declined_call_str = stock_str::declined_call(self).await;
let declined_call_str = stock_str::declined_call(self);
call.update_text(self, &declined_call_str).await?;
} else {
call.mark_as_canceled(self).await?;
let missed_call_str = stock_str::missed_call(self).await;
let missed_call_str = stock_str::missed_call(self);
call.update_text(self, &missed_call_str).await?;
}
} else {
// outgoing
if from_id == ContactId::SELF {
call.mark_as_canceled(self).await?;
let canceled_call_str = stock_str::canceled_call(self).await;
let canceled_call_str = stock_str::canceled_call(self);
call.update_text(self, &canceled_call_str).await?;
} else {
call.mark_as_ended(self).await?;
let declined_call_str = stock_str::declined_call(self).await;
let declined_call_str = stock_str::declined_call(self);
call.update_text(self, &declined_call_str).await?;
}
}

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::chat::forward_msgs;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::message::MessageState;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
@@ -115,9 +116,28 @@ async fn accept_call() -> Result<CallSetup> {
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
.await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: true,
..
}
)
})
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob
@@ -131,7 +151,15 @@ async fn accept_call() -> Result<CallSetup> {
bob2.recv_msg_trash(&sent2).await;
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: false,
..
}
)
})
.await;
let info = bob2
.load_call_by_id(bob2_call.id)
@@ -200,9 +228,20 @@ async fn test_accept_call_callee_ends() -> Result<()> {
bob2_call,
..
} = accept_call().await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -328,8 +367,18 @@ async fn test_callee_rejects_call() -> Result<()> {
} = setup_call().await?;
// Bob has accepted Alice before, but does not want to talk with Alice
bob_call.chat_id.accept(&bob).await?;
bob.end_call(bob_call.id).await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Declined call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -370,6 +419,35 @@ async fn test_callee_rejects_call() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_sees_contact_request_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.await?;
let sent1 = alice.pop_sent_msg().await;
let bob_call = bob.recv_msg(&sent1).await;
// Bob can't end_call() because the contact request isn't accepted, but he can mark the call as
// seen.
markseen_msgs(bob, vec![bob_call.id]).await?;
assert_eq!(bob_call.id.get_state(bob).await?, MessageState::InSeen);
// Bob sends an MDN only to self so that an unaccepted contact can't know anything.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, ContactId::SELF)
)
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob

View File

@@ -476,13 +476,18 @@ impl ChatId {
/// Adds message "Messages are end-to-end encrypted".
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
let text = stock_str::messages_e2ee_info_msg(context).await;
let text = stock_str::messages_e2ee_info_msg(context);
// Sort this notice to the very beginning of the chat.
// We don't want any message to appear before this notice
// which is normally added when encrypted chat is created.
let sort_timestamp = 0;
add_info_msg_with_cmd(
context,
self,
&text,
SystemMessage::ChatE2ee,
Some(timestamp),
Some(sort_timestamp),
timestamp,
None,
None,
@@ -669,7 +674,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
}
if chat.is_self_talk() {
let mut msg = Message::new_text(stock_str::self_deleted_msg_body(context).await);
let mut msg = Message::new_text(stock_str::self_deleted_msg_body(context));
add_device_msg(context, None, Some(&mut msg)).await?;
}
chatlist_events::emit_chatlist_changed(context);
@@ -925,6 +930,17 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
.unwrap_or(0))
}
/// Returns timestamp of us joining the chat if we are the member of the chat.
pub(crate) async fn join_timestamp(self, context: &Context) -> Result<Option<i64>> {
context
.sql
.query_get_value(
"SELECT add_timestamp FROM chats_contacts WHERE chat_id=? AND contact_id=?",
(self, ContactId::SELF),
)
.await
}
/// Returns timestamp of the latest message in the chat,
/// including hidden messages or a draft if there is one.
pub(crate) async fn get_timestamp(self, context: &Context) -> Result<Option<i64>> {
@@ -1155,10 +1171,10 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
pub async fn get_encryption_info(self, context: &Context) -> Result<String> {
let chat = Chat::load_from_db(context, self).await?;
if !chat.is_encrypted(context).await? {
return Ok(stock_str::encr_none(context).await);
return Ok(stock_str::encr_none(context));
}
let mut ret = stock_str::messages_are_e2ee(context).await + "\n";
let mut ret = stock_str::messages_are_e2ee(context) + "\n";
for &contact_id in get_chat_contacts(context, self)
.await?
@@ -1212,15 +1228,11 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
/// corresponding event in case of a system message (usually the current system time).
/// `always_sort_to_bottom` makes this adjust the returned timestamp up so that the message goes
/// to the chat bottom.
/// `received` -- whether the message is received. Otherwise being sent.
/// `incoming` -- whether the message is incoming.
pub(crate) async fn calc_sort_timestamp(
self,
context: &Context,
message_timestamp: i64,
always_sort_to_bottom: bool,
received: bool,
incoming: bool,
) -> Result<i64> {
let mut sort_timestamp = cmp::min(message_timestamp, smeared_time(context));
@@ -1240,38 +1252,6 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
(self, MessageState::OutDraft),
)
.await?
} else if received {
// Received messages shouldn't mingle with just sent ones and appear somewhere in the
// middle of the chat, so we go after the newest non fresh message.
//
// But if a received outgoing message is older than some seen message, better sort the
// received message purely by timestamp. We could place it just before that seen
// message, but anyway the user may not notice it.
//
// NB: Received outgoing messages may break sorting of fresh incoming ones, but this
// shouldn't happen frequently. Seen incoming messages don't really break sorting of
// fresh ones, they rather mean that older incoming messages are actually seen as well.
context
.sql
.query_row_optional(
"SELECT MAX(timestamp), MAX(IIF(state=?,timestamp_sent,0))
FROM msgs
WHERE chat_id=? AND hidden=0 AND state>?
HAVING COUNT(*) > 0",
(MessageState::InSeen, self, MessageState::InFresh),
|row| {
let ts: i64 = row.get(0)?;
let ts_sent_seen: i64 = row.get(1)?;
Ok((ts, ts_sent_seen))
},
)
.await?
.and_then(|(ts, ts_sent_seen)| {
match incoming || ts_sent_seen <= message_timestamp {
true => Some(ts),
false => None,
}
})
} else {
None
};
@@ -1282,7 +1262,16 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
sort_timestamp = last_msg_time;
}
Ok(sort_timestamp)
if let Some(join_timestamp) = self.join_timestamp(context).await? {
// If we are the member of the chat, don't add messages
// before the timestamp of us joining it.
// This is needed to avoid sorting "Member added"
// or automatically sent bot welcome messages
// above SecureJoin system messages.
Ok(std::cmp::max(sort_timestamp, join_timestamp))
} else {
Ok(sort_timestamp)
}
}
}
@@ -1392,7 +1381,7 @@ impl Chat {
.context(format!("Failed loading chat {chat_id} from database"))?;
if chat.id.is_archived_link() {
chat.name = stock_str::archived_chats(context).await;
chat.name = stock_str::archived_chats(context);
} else {
if chat.typ == Chattype::Single && chat.name.is_empty() {
// chat.name is set to contact.display_name on changes,
@@ -1416,9 +1405,9 @@ impl Chat {
chat.name = chat_name;
}
if chat.param.exists(Param::Selftalk) {
chat.name = stock_str::saved_messages(context).await;
chat.name = stock_str::saved_messages(context);
} else if chat.param.exists(Param::Devicetalk) {
chat.name = stock_str::device_messages(context).await;
chat.name = stock_str::device_messages(context);
}
}
@@ -2306,15 +2295,10 @@ pub(crate) async fn update_special_chat_names(context: &Context) -> Result<()> {
update_special_chat_name(
context,
ContactId::DEVICE,
stock_str::device_messages(context).await,
)
.await?;
update_special_chat_name(
context,
ContactId::SELF,
stock_str::saved_messages(context).await,
stock_str::device_messages(context),
)
.await?;
update_special_chat_name(context, ContactId::SELF, stock_str::saved_messages(context)).await?;
Ok(())
}
@@ -2844,19 +2828,12 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
let lowercase_from = from.to_lowercase();
recipients.retain(|x| x.to_lowercase() != lowercase_from);
if context.get_config_bool(Config::BccSelf).await?
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage
// Default Webxdc integrations are hidden messages and must not be sent out:
if (msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden)
// This may happen eg. for groups with only SELF and bcc_self disabled:
|| (!context.get_config_bool(Config::BccSelf).await? && recipients.is_empty())
{
smtp::add_self_recipients(context, &mut recipients, needs_encryption).await?;
}
// Default Webxdc integrations are hidden messages and must not be sent out
if msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden {
recipients.clear();
}
if recipients.is_empty() {
// may happen eg. for groups with only SELF and bcc_self disabled
info!(
context,
"Message {} has no recipient, skipping smtp-send.", msg.id
@@ -2895,6 +2872,10 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
);
}
if context.get_config_bool(Config::BccSelf).await? {
smtp::add_self_recipients(context, &mut recipients, rendered_msg.is_encrypted).await?;
}
if needs_encryption && !rendered_msg.is_encrypted {
/* unrecoverable */
message::set_msg_failed(
@@ -3071,7 +3052,7 @@ async fn donation_request_maybe(context: &Context) -> Result<()> {
let ts = if ts == 0 || msg_cnt.await? < 100 {
now.saturating_add(secs_between_checks)
} else {
let mut msg = Message::new_text(stock_str::donation_request(context).await);
let mut msg = Message::new_text(stock_str::donation_request(context));
add_device_msg(context, None, Some(&mut msg)).await?;
i64::MAX
};
@@ -3406,6 +3387,38 @@ pub(crate) async fn mark_old_messages_as_noticed(
Ok(())
}
/// Marks last incoming message in a chat as fresh.
pub async fn markfresh_chat(context: &Context, chat_id: ChatId) -> Result<()> {
let affected_rows = context
.sql
.execute(
"UPDATE msgs
SET state=?1
WHERE id=(SELECT id
FROM msgs
WHERE state IN (?1, ?2, ?3) AND hidden=0 AND chat_id=?4
ORDER BY timestamp DESC, id DESC
LIMIT 1)
AND state!=?1",
(
MessageState::InFresh,
MessageState::InNoticed,
MessageState::InSeen,
chat_id,
),
)
.await?;
if affected_rows == 0 {
return Ok(());
}
context.emit_msgs_changed_without_msg_id(chat_id);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
/// Returns all database message IDs of the given types.
///
/// If `chat_id` is None, return messages from any chat.
@@ -3593,10 +3606,10 @@ pub(crate) async fn create_group_ex(
{
let text = if !grpid.is_empty() {
// Add "Others will only see this group after you sent a first message." message.
stock_str::new_group_send_first_message(context).await
stock_str::new_group_send_first_message(context)
} else {
// Add "Messages in this chat use classic email and are not encrypted." message.
stock_str::chat_unencrypted_explanation(context).await
stock_str::chat_unencrypted_explanation(context)
};
add_info_msg(context, chat_id, &text).await?;
}
@@ -4168,7 +4181,7 @@ async fn send_member_removal_msg(
if contact_id == ContactId::SELF {
if chat.typ == Chattype::InBroadcast {
msg.text = stock_str::msg_you_left_broadcast(context).await;
msg.text = stock_str::msg_you_left_broadcast(context);
} else {
msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await;
}
@@ -4329,7 +4342,7 @@ async fn rename_ex(
{
msg.viewtype = Viewtype::Text;
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name).await
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name)
} else {
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await
};
@@ -4394,7 +4407,7 @@ pub async fn set_chat_profile_image(
chat.param.remove(Param::ProfileImage);
msg.param.remove(Param::Arg);
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_img_changed(context).await
stock_str::msg_broadcast_img_changed(context)
} else {
stock_str::msg_grp_img_deleted(context, ContactId::SELF).await
};
@@ -4408,7 +4421,7 @@ pub async fn set_chat_profile_image(
chat.param.set(Param::ProfileImage, image_blob.as_name());
msg.param.set(Param::Arg, image_blob.as_name());
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_img_changed(context).await
stock_str::msg_broadcast_img_changed(context)
} else {
stock_str::msg_grp_img_changed(context, ContactId::SELF).await
};
@@ -4914,15 +4927,8 @@ pub(crate) async fn add_info_msg_with_cmd(
ts
} else {
let sort_to_bottom = true;
let (received, incoming) = (false, false);
chat_id
.calc_sort_timestamp(
context,
smeared_time(context),
sort_to_bottom,
received,
incoming,
)
.calc_sort_timestamp(context, smeared_time(context), sort_to_bottom)
.await?
};

View File

@@ -806,7 +806,7 @@ async fn test_self_talk() -> Result<()> {
assert!(chat.visibility == ChatVisibility::Normal);
assert!(!chat.is_device_talk());
assert!(chat.can_send(&t).await?);
assert_eq!(chat.name, stock_str::saved_messages(&t).await);
assert_eq!(chat.name, stock_str::saved_messages(&t));
assert!(chat.get_profile_image(&t).await?.is_some());
let msg_id = send_text_msg(&t, chat.id, "foo self".to_string()).await?;
@@ -866,7 +866,6 @@ async fn test_add_device_msg_unlabelled() {
assert_eq!(msg1.from_id, ContactId::DEVICE);
assert_eq!(msg1.to_id, ContactId::SELF);
assert!(!msg1.is_info());
assert!(!msg1.is_setupmessage());
let msg2 = message::Message::load_from_db(&t, msg2_id.unwrap()).await;
assert!(msg2.is_ok());
@@ -899,7 +898,6 @@ async fn test_add_device_msg_labelled() -> Result<()> {
assert_eq!(msg1.from_id, ContactId::DEVICE);
assert_eq!(msg1.to_id, ContactId::SELF);
assert!(!msg1.is_info());
assert!(!msg1.is_setupmessage());
// check device chat
let chat_id = msg1.chat_id;
@@ -913,7 +911,7 @@ async fn test_add_device_msg_labelled() -> Result<()> {
assert!(!chat.can_send(&t).await?);
assert!(chat.why_cant_send(&t).await? == Some(CantSendReason::DeviceChat));
assert_eq!(chat.name, stock_str::device_messages(&t).await);
assert_eq!(chat.name, stock_str::device_messages(&t));
let device_msg_icon = chat.get_profile_image(&t).await?.unwrap();
assert_eq!(
device_msg_icon.metadata()?.len(),
@@ -1331,6 +1329,54 @@ async fn test_marknoticed_all_chats() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markfresh_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// alice sends a message to Bob
let alice_chat = alice.create_chat(bob).await;
let sent_msg1 = alice.send_text(alice_chat.id, "hi bob!").await;
// bob received the message, fresh count is 1
let bob_msg1 = bob.recv_msg(&sent_msg1).await;
let bob_chat_id = bob_msg1.chat_id;
bob_chat_id.accept(bob).await?;
assert_eq!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 1);
assert_eq!(bob.get_fresh_msgs().await?.len(), 1);
// alice sends another message to bob, fresh count is 2
let sent_msg2 = alice.send_text(alice_chat.id, "howdy?").await;
let bob_msg2 = bob.recv_msg(&sent_msg2).await;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
assert_eq!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 2);
assert_eq!(bob.get_fresh_msgs().await?.len(), 2);
// bob marks the chat as noticed, messages are no longer fresh, fresh count is 0
marknoticed_chat(bob, bob_chat_id).await?;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
let bob_msg2 = Message::load_from_db(bob, bob_msg2.id).await?;
assert_ne!(bob_msg1.state, MessageState::InFresh);
assert_ne!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 0);
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
// bob marks the chat as fresh again, fresh count is 1 again
markfresh_chat(bob, bob_chat_id).await?;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
let bob_msg2 = Message::load_from_db(bob, bob_msg2.id).await?;
assert_ne!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 1);
assert_eq!(bob.get_fresh_msgs().await?.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archive_fresh_msgs() -> Result<()> {
let t = TestContext::new_alice().await;
@@ -2746,7 +2792,7 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&vc_pubkey).await;
assert!(parsed_by_bob.decrypting_failed);
assert!(parsed_by_bob.decryption_error.is_some());
charlie.recv_msg_trash(&vc_pubkey).await;
}
@@ -2775,7 +2821,7 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_added).await;
assert!(parsed_by_bob.decrypting_failed);
assert!(parsed_by_bob.decryption_error.is_some());
let rcvd = charlie.recv_msg(&member_added).await;
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
@@ -2790,7 +2836,7 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&hi_msg).await;
assert_eq!(parsed_by_bob.decrypting_failed, false);
assert!(parsed_by_bob.decryption_error.is_none());
}
tcm.section("Alice removes Charlie. Bob must not see it.");
@@ -2807,7 +2853,7 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_removed).await;
assert!(parsed_by_bob.decrypting_failed);
assert!(parsed_by_bob.decryption_error.is_some());
let rcvd = charlie.recv_msg(&member_removed).await;
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberRemovedFromGroup);
@@ -3289,7 +3335,12 @@ async fn test_chat_description(
initial_description
);
for description in ["This is a cool chat", "", "ä ẟ 😂"] {
for description in [
&"This<>is 'a' \"cool\" chat:/\\|?*".repeat(50),
"multiple\nline\n\nbreaks\n\n\r\n.",
"",
"ä ẟ 😂",
] {
tcm.section(&format!(
"Alice sets the chat description to '{description}'"
));
@@ -3746,7 +3797,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
assert_eq!(rcvd.chat_id, bob1_hello.chat_id);
assert!(rcvd.is_info());
assert_eq!(rcvd.get_info_type(), SystemMessage::MemberRemovedFromGroup);
assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1).await);
assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1));
Ok(())
}
@@ -4733,9 +4784,10 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
vec![a2b_contact_id]
);
// alice2's smeared clock may be behind alice's one, so "hi" from alice2 may appear before "You
// joined the channel." for bob.
SystemTime::shift(Duration::from_secs(1));
// alice2's smeared clock may be behind alice1's one, so we need to work around "hi" appearing
// before "You joined the channel." for bob. alice1 makes 3 more calls of
// create_smeared_timestamp() than alice2 does as of 2026-03-10.
SystemTime::shift(Duration::from_secs(3));
tcm.section("Alice's second device sends a message to the channel");
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
let msg = bob.recv_msg(&sent_msg).await;
@@ -6060,3 +6112,54 @@ async fn test_leftgrps() -> Result<()> {
Ok(())
}
/// Tests that if the message arrives late,
/// it can still be sorted above the last seen message.
///
/// Versions 2.47 and below always sorted incoming messages
/// after the last seen message, but with
/// the introduction of multi-relay it is possible
/// that some messages arrive only to some relays
/// and are fetched after the already arrived seen message.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_late_message_above_seen() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let alice_chat_id = alice
.create_group_with_members("Group", &[bob, charlie])
.await;
let alice_sent = alice.send_text(alice_chat_id, "Hello everyone!").await;
let bob_chat_id = bob.recv_msg(&alice_sent).await.chat_id;
bob_chat_id.accept(bob).await?;
let charlie_chat_id = charlie.recv_msg(&alice_sent).await.chat_id;
charlie_chat_id.accept(charlie).await?;
// Bob sends a message, but the message is delayed.
let bob_sent = bob.send_text(bob_chat_id, "Hello from Bob!").await;
SystemTime::shift(Duration::from_secs(1000));
let charlie_sent = charlie
.send_text(charlie_chat_id, "Hello from Charlie!")
.await;
// Alice immediately receives a message from Charlie and reads it.
let alice_received_from_charlie = alice.recv_msg(&charlie_sent).await;
assert_eq!(
alice_received_from_charlie.get_text(),
"Hello from Charlie!"
);
message::markseen_msgs(alice, vec![alice_received_from_charlie.id]).await?;
// Bob message arrives later, it should be above the message from Charlie.
let alice_received_from_bob = alice.recv_msg(&bob_sent).await;
assert_eq!(alice_received_from_bob.get_text(), "Hello from Bob!");
// The last message in the chat is still from Charlie.
let last_msg = alice.get_last_msg_in(alice_chat_id).await;
assert_eq!(last_msg.get_text(), "Hello from Charlie!");
Ok(())
}

View File

@@ -142,7 +142,7 @@ impl Chatlist {
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(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
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),
process_row,
).await?
@@ -168,7 +168,7 @@ impl Chatlist {
AND c.blocked!=1
AND c.archived=1
GROUP BY c.id
ORDER BY IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
ORDER BY IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft,),
process_row,
)
@@ -204,7 +204,7 @@ impl Chatlist {
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(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
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),
process_row,
)
@@ -253,7 +253,7 @@ impl Chatlist {
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(m.timestamp,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,
Chattype::Group, ContactId::SELF,
@@ -279,7 +279,7 @@ impl Chatlist {
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(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
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),
process_row,
).await?
@@ -417,7 +417,7 @@ impl Chatlist {
Summary::new_with_reaction_details(context, &lastmsg, chat, lastcontact.as_ref()).await
} else {
Ok(Summary {
text: stock_str::no_messages(context).await,
text: stock_str::no_messages(context),
..Default::default()
})
}
@@ -648,7 +648,6 @@ mod tests {
assert_eq!(chats.len(), 0);
t.set_stock_translation(StockMessage::SavedMessages, "test-1234-save".to_string())
.await
.unwrap();
let chats = Chatlist::try_load(&t, 0, Some("t-1234-s"), None)
.await
@@ -656,7 +655,6 @@ mod tests {
assert_eq!(chats.len(), 1);
t.set_stock_translation(StockMessage::DeviceMessages, "test-5678-babbel".to_string())
.await
.unwrap();
let chats = Chatlist::try_load(&t, 0, Some("t-5678-b"), None)
.await

View File

@@ -837,7 +837,7 @@ impl Context {
// which only fetches from the primary transport.
transaction
.execute(
"UPDATE transports SET add_timestamp=? WHERE addr=?",
"UPDATE transports SET add_timestamp=?, is_published=1 WHERE addr=?",
(time(), addr),
)
.context(
@@ -964,7 +964,22 @@ impl Context {
pub(crate) async fn get_all_self_addrs(&self) -> Result<Vec<String>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports ORDER BY add_timestamp DESC",
"SELECT addr FROM transports ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
/// Returns all published self addresses, newest first.
/// See `[Context::set_transport_unpublished]`
pub(crate) async fn get_published_self_addrs(&self) -> Result<Vec<String>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports WHERE is_published=1 ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
@@ -982,6 +997,24 @@ impl Context {
}).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>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports
WHERE is_published
AND addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')
ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
/// Returns the primary self address.
/// Returns an error if no self addr is configured.
pub async fn get_primary_self_addr(&self) -> Result<String> {

View File

@@ -246,8 +246,7 @@ async fn test_sync() -> Result<()> {
alice1
.get_config(Config::Selfavatar)
.await?
.filter(|path| path.ends_with(".png"))
.is_some()
.is_some_and(|path| path.ends_with(".png"))
);
alice0.set_config(Config::Selfavatar, None).await?;
sync(&alice0, &alice1).await;
@@ -305,16 +304,14 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
alice1
.get_config(Config::Selfavatar)
.await?
.filter(|path| path.ends_with(".jpg"))
.is_some()
.is_some_and(|path| path.ends_with(".jpg"))
);
sync(alice1, alice0).await;
assert!(
alice0
.get_config(Config::Selfavatar)
.await?
.filter(|path| path.ends_with(".jpg"))
.is_some()
.is_some_and(|path| path.ends_with(".jpg"))
);
Ok(())

View File

@@ -28,8 +28,8 @@ use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::imap::Imap;
use crate::log::warn;
use crate::login_param::EnteredCertificateChecks;
pub use crate::login_param::EnteredLoginParam;
use crate::login_param::{EnteredCertificateChecks, TransportListEntry};
use crate::message::Message;
use crate::net::proxy::ProxyConfig;
use crate::oauth2::get_oauth2_addr;
@@ -110,6 +110,7 @@ impl Context {
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
pub async fn add_or_update_transport(&self, param: &mut EnteredLoginParam) -> Result<()> {
self.stop_io().await;
let result = self.add_transport_inner(param).await;
@@ -145,7 +146,7 @@ impl Context {
if let Err(err) = res.as_ref() {
// We are using Anyhow's .context() and to show the
// inner error, too, we need the {:#}:
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}")).await;
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}"));
progress!(self, 0, Some(error_msg.clone()));
bail!(error_msg);
} else {
@@ -188,14 +189,22 @@ impl Context {
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
pub async fn list_transports(&self) -> Result<Vec<EnteredLoginParam>> {
pub async fn list_transports(&self) -> Result<Vec<TransportListEntry>> {
let transports = self
.sql
.query_map_vec("SELECT entered_param FROM transports", (), |row| {
let entered_param: String = row.get(0)?;
let transport: EnteredLoginParam = serde_json::from_str(&entered_param)?;
Ok(transport)
})
.query_map_vec(
"SELECT entered_param, is_published FROM transports",
(),
|row| {
let param: String = row.get(0)?;
let param: EnteredLoginParam = serde_json::from_str(&param)?;
let is_published: bool = row.get(1)?;
Ok(TransportListEntry {
param,
is_unpublished: !is_published,
})
},
)
.await?;
Ok(transports)
@@ -234,11 +243,6 @@ impl Context {
Ok((id, add_timestamp))
},
)?;
transaction.execute("DELETE FROM imap WHERE transport_id=?", (transport_id,))?;
transaction.execute(
"DELETE FROM imap_sync WHERE transport_id=?",
(transport_id,),
)?;
// Removal timestamp should not be lower than addition timestamp
// to be accepted by other devices when synced.
@@ -261,6 +265,44 @@ impl Context {
Ok(())
}
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// 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.
///
/// The default is false, but when the user updates from a version that didn't have this flag,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
pub async fn set_transport_unpublished(&self, addr: &str, unpublished: bool) -> Result<()> {
self.sql
.transaction(|trans| {
let primary_addr: String = trans
.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| row.get(0),
)
.context("Select primary address")?;
if primary_addr == addr && unpublished {
bail!("Can't set primary relay as unpublished");
}
// We need to update the timestamp so that the key's timestamp changes
// and is recognized as newer by our peers
trans
.execute(
"UPDATE transports SET is_published=?, add_timestamp=? WHERE addr=? AND is_published!=?1",
(!unpublished, time(), addr),
)
.context("Update transports")?;
Ok(())
})
.await?;
send_sync_transports(self).await?;
Ok(())
}
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
info!(self, "Configure ...");
@@ -590,10 +632,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
let imap_session = match imap.connect(ctx, configuring).await {
Ok(imap_session) => imap_session,
Err(err) => {
bail!(
"{}",
nicer_configuration_error(ctx, format!("{err:#}")).await
);
bail!("{}", nicer_configuration_error(ctx, format!("{err:#}")));
}
};
@@ -734,7 +773,7 @@ async fn get_autoconfig(
None
}
async fn nicer_configuration_error(context: &Context, e: String) -> String {
fn nicer_configuration_error(context: &Context, e: String) -> String {
if e.to_lowercase().contains("could not resolve")
|| e.to_lowercase().contains("connection attempts")
|| e.to_lowercase()
@@ -743,7 +782,7 @@ async fn nicer_configuration_error(context: &Context, e: String) -> String {
|| e.to_lowercase()
.contains("failed to lookup address information")
{
return stock_str::error_no_network(context).await;
return stock_str::error_no_network(context);
}
e

View File

@@ -234,19 +234,6 @@ pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;
// Newer Delta Chats will remove the prefix as needed.
pub(crate) const EDITED_PREFIX: &str = "✏️";
// Strings needed to render the Autocrypt Setup Message.
// Left untranslated as not being supported/recommended workflow and as translations would require deep knowledge.
pub(crate) const ASM_SUBJECT: &str = "Autocrypt Setup Message";
pub(crate) const ASM_BODY: &str = "This is the Autocrypt Setup Message \
used to transfer your end-to-end setup between clients.
To decrypt and use your setup, \
open the message in an Autocrypt-compliant client \
and enter the setup code presented on the generating device.
If you see this message in a chatmail client (Delta Chat, Arcane Chat, Delta Touch ...), \
use \"Settings / Add Second Device\" instead.";
/// Period between `sql::housekeeping()` runs.
pub(crate) const HOUSEKEEPING_PERIOD: i64 = 24 * 60 * 60;

View File

@@ -688,7 +688,7 @@ impl Contact {
.await?
{
if contact_id == ContactId::SELF {
contact.name = stock_str::self_msg(context).await;
contact.name = stock_str::self_msg(context);
contact.authname = context
.get_config(Config::Displayname)
.await?
@@ -705,9 +705,9 @@ impl Contact {
.await?
.unwrap_or_default();
} else if contact_id == ContactId::DEVICE {
contact.name = stock_str::device_messages(context).await;
contact.name = stock_str::device_messages(context);
contact.addr = ContactId::DEVICE_ADDR.to_string();
contact.status = stock_str::device_messages_hint(context).await;
contact.status = stock_str::device_messages_hint(context);
}
Ok(Some(contact))
} else {
@@ -1240,7 +1240,7 @@ ORDER BY c.origin>=? DESC, c.last_seen DESC, c.id DESC
if self_addr.contains(query)
|| self_name.contains(query)
|| self_name2.await.contains(query)
|| self_name2.contains(query)
{
add_self = true;
}
@@ -1392,17 +1392,17 @@ WHERE addr=?
.unwrap_or_default();
let Some(fingerprint_other) = contact.fingerprint() else {
return Ok(stock_str::encr_none(context).await);
return Ok(stock_str::encr_none(context));
};
let fingerprint_other = fingerprint_other.to_string();
let stock_message = if contact.public_key(context).await?.is_some() {
stock_str::messages_are_e2ee(context).await
stock_str::messages_are_e2ee(context)
} else {
stock_str::encr_none(context).await
stock_str::encr_none(context)
};
let finger_prints = stock_str::finger_prints(context).await;
let finger_prints = stock_str::finger_prints(context);
let mut ret = format!("{stock_message}\n{finger_prints}:");
let fingerprint_self = load_self_public_key(context)
@@ -1412,7 +1412,7 @@ WHERE addr=?
if addr < contact.addr {
cat_fingerprint(
&mut ret,
&stock_str::self_msg(context).await,
&stock_str::self_msg(context),
&addr,
&fingerprint_self,
);
@@ -1431,7 +1431,7 @@ WHERE addr=?
);
cat_fingerprint(
&mut ret,
&stock_str::self_msg(context).await,
&stock_str::self_msg(context),
&addr,
&fingerprint_self,
);

View File

@@ -282,7 +282,7 @@ async fn test_add_or_lookup() {
// check SELF
let contact = Contact::get_by_id(&t, ContactId::SELF).await.unwrap();
assert_eq!(contact.get_name(), stock_str::self_msg(&t).await);
assert_eq!(contact.get_name(), stock_str::self_msg(&t));
assert_eq!(contact.get_addr(), "alice@example.org");
assert!(!contact.is_blocked());
}

View File

@@ -266,15 +266,11 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
}
}
}
"b" | "strong" => {
if dehtml.get_add_text() != AddText::No {
*dehtml.get_buf() += "*";
}
"b" | "strong" if dehtml.get_add_text() != AddText::No => {
*dehtml.get_buf() += "*";
}
"i" | "em" => {
if dehtml.get_add_text() != AddText::No {
*dehtml.get_buf() += "_";
}
"i" | "em" if dehtml.get_add_text() != AddText::No => {
*dehtml.get_buf() += "_";
}
"blockquote" => pop_tag(&mut dehtml.blockquotes_since_blockquote),
_ => {}
@@ -341,15 +337,11 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
}
}
}
"b" | "strong" => {
if dehtml.get_add_text() != AddText::No {
*dehtml.get_buf() += "*";
}
"b" | "strong" if dehtml.get_add_text() != AddText::No => {
*dehtml.get_buf() += "*";
}
"i" | "em" => {
if dehtml.get_add_text() != AddText::No {
*dehtml.get_buf() += "_";
}
"i" | "em" if dehtml.get_add_text() != AddText::No => {
*dehtml.get_buf() += "_";
}
"blockquote" => dehtml.blockquotes_since_blockquote += 1,
_ => {}

View File

@@ -165,6 +165,7 @@ pub(crate) async fn download_msg(
let Some((server_uid, server_folder, msg_transport_id)) = row else {
// No IMAP record found, we don't know the UID and folder.
delete_from_available_post_msgs(context, &rfc724_mid).await?;
return Err(anyhow!(
"IMAP location for {rfc724_mid:?} post-message is unknown"
));
@@ -326,22 +327,25 @@ pub(crate) async fn download_known_post_messages_without_pre_message(
})
.await?;
for rfc724_mid in &rfc724_mids {
if !msg_is_downloaded_for(context, rfc724_mid).await? {
// Download the Post-Message unconditionally,
// because the Pre-Message got lost.
// The message may be in the wrong order,
// but at least we have it at all.
let res = download_msg(context, rfc724_mid.clone(), session).await;
if let Ok(Some(())) = res {
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
if let Err(err) = res {
warn!(
context,
"download_known_post_messages_without_pre_message: Failed to download message rfc724_mid={rfc724_mid}: {:#}.",
err
);
}
if msg_is_downloaded_for(context, rfc724_mid).await? {
delete_from_available_post_msgs(context, rfc724_mid).await?;
continue;
}
// Download the Post-Message unconditionally,
// because the Pre-Message got lost.
// The message may be in the wrong order,
// but at least we have it at all.
let res = download_msg(context, rfc724_mid.clone(), session).await;
if let Ok(Some(())) = res {
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
if let Err(err) = res {
warn!(
context,
"download_known_post_messages_without_pre_message: Failed to download message rfc724_mid={rfc724_mid}: {:#}.",
err
);
}
}
Ok(())

View File

@@ -40,7 +40,6 @@ impl EncryptHelper {
keyring: Vec<SignedPublicKey>,
mail_to_encrypt: MimePart<'static>,
compress: bool,
anonymous_recipients: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
@@ -49,15 +48,8 @@ impl EncryptHelper {
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();
let ctext = pgp::pk_encrypt(
raw_message,
keyring,
sign_key,
compress,
anonymous_recipients,
seipd_version,
)
.await?;
let ctext =
pgp::pk_encrypt(raw_message, keyring, sign_key, compress, seipd_version).await?;
Ok(ctext)
}

View File

@@ -57,7 +57,7 @@ pub enum EventType {
/// should not be disturbed by a dialog or so. Instead, use a bubble or so.
///
/// However, for ongoing processes (eg. configure())
/// or for functions that are expected to fail (eg. dc_continue_key_transfer())
/// or for functions that are expected to fail
/// it might be better to delay showing these events until the function has really
/// failed (returned false). It should be sufficient to report only the *last* error
/// in a message box then.
@@ -397,6 +397,8 @@ pub enum EventType {
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.

View File

@@ -125,7 +125,6 @@ pub enum HeaderDef {
/// [Autocrypt](https://autocrypt.org/) header.
Autocrypt,
AutocryptGossip,
AutocryptSetupMessage,
SecureJoin,
/// Deprecated header containing Group-ID in `vg-request-with-auth` message.
@@ -208,10 +207,10 @@ mod tests {
/// Test that headers are parsed case-insensitively
fn test_get_header_value_case() {
let (headers, _) =
mailparse::parse_headers(b"fRoM: Bob\naUtoCryPt-SeTup-MessAge: v99").unwrap();
mailparse::parse_headers(b"fRoM: Bob\naUtoCryPt-GoSsIp: fooBaR").unwrap();
assert_eq!(
headers.get_header_value(HeaderDef::AutocryptSetupMessage),
Some("v99".to_string())
headers.get_header_value(HeaderDef::AutocryptGossip),
Some("fooBaR".to_string())
);
assert_eq!(
headers.get_header_value(HeaderDef::From_),

View File

@@ -296,6 +296,11 @@ impl Imap {
Ok(imap)
}
/// Returns transport ID of the IMAP client.
pub fn transport_id(&self) -> u32 {
self.transport_id
}
/// Connects to IMAP server and returns a new IMAP session.
///
/// Calling this function is not enough to perform IMAP operations. Use [`Imap::prepare`]
@@ -437,7 +442,7 @@ impl Imap {
Err(err) => {
let imap_user = lp.user.to_owned();
let message = stock_str::cannot_login(context, &imap_user).await;
let message = stock_str::cannot_login(context, &imap_user);
warn!(context, "IMAP failed to login: {err:#}.");
first_error.get_or_insert(format_err!("{message} ({err:#})"));
@@ -551,8 +556,13 @@ impl Imap {
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<bool> {
let transport_id = session.transport_id();
if should_ignore_folder(context, folder, folder_meaning).await? {
info!(context, "Not fetching from {folder:?}.");
info!(
context,
"Transport {transport_id}: Not fetching from {folder:?}."
);
session.new_mail = false;
return Ok(false);
}
@@ -561,16 +571,22 @@ impl Imap {
.select_with_uidvalidity(context, folder)
.await
.with_context(|| format!("Failed to select folder {folder:?}"))?;
if !session.new_mail {
info!(
context,
"Transport {transport_id}: No new emails in folder {folder:?}."
);
return Ok(false);
}
// Make sure not to return before setting new_mail to false
// Otherwise, we will skip IDLE and go into an infinite loop
session.new_mail = false;
if !folder_exists {
return Ok(false);
}
if !session.new_mail {
info!(context, "No new emails in folder {folder:?}.");
return Ok(false);
}
session.new_mail = false;
let mut read_cnt = 0;
loop {
let (n, fetch_more) = self
@@ -1087,15 +1103,22 @@ impl Session {
return Ok(());
}
context
.sql
.execute(
"DELETE FROM imap_markseen WHERE id NOT IN (SELECT imap.id FROM imap)",
(),
)
.await?;
let transport_id = self.transport_id();
let rows = context
let mut rows = context
.sql
.query_map_vec(
"SELECT imap.id, uid, folder FROM imap, imap_markseen
WHERE imap.id = imap_markseen.id
AND imap.transport_id=?
AND target = folder
ORDER BY folder, uid",
AND target = folder",
(transport_id,),
|row| {
let rowid: i64 = row.get(0)?;
@@ -1106,6 +1129,16 @@ impl Session {
)
.await?;
// Number of SQL results is expected to be low as
// we usually don't have many messages to mark on IMAP at once.
// We are sorting outside of SQL to avoid SQLite constructing a query plan
// that scans the whole `imap` table. Scanning `imap_markseen` is fine
// as it should not have many items.
// If you change the SQL query, test it with `EXPLAIN QUERY PLAN`.
rows.sort_unstable_by(|(_rowid1, uid1, folder1), (_rowid2, uid2, folder2)| {
(folder1, uid1).cmp(&(folder2, uid2))
});
for (folder, rowid_set, uid_set) in UidGrouper::from(rows) {
let folder_exists = match self.select_with_uidvalidity(context, &folder).await {
Err(err) => {
@@ -1237,6 +1270,7 @@ impl Session {
// have been modified while our request was in progress.
// We may or may not have these new flags as a part of the response,
// so better skip next IDLE and do another round of flag synchronization.
info!(context, "Got unsolicited fetch, will skip idle");
self.new_mail = true;
}
@@ -1562,11 +1596,18 @@ impl Session {
return Ok(());
}
let transport_id = self.transport_id();
let Some(device_token) = context.push_subscriber.device_token().await else {
return Ok(());
};
if self.can_metadata() && self.can_push() {
info!(
context,
"Transport {transport_id}: Subscribing for push notifications."
);
let old_encrypted_device_token =
context.get_config(Config::EncryptedDeviceToken).await?;
@@ -1974,15 +2015,6 @@ async fn needs_move_to_mvbox(
return Ok(false);
}
if headers
.get_header_value(HeaderDef::AutocryptSetupMessage)
.is_some()
{
// do not move setup messages;
// there may be a non-delta device that wants to handle it
return Ok(false);
}
if has_chat_version {
Ok(true)
} else if let Some(parent) = get_prefetch_parent_message(context, headers).await? {

View File

@@ -27,6 +27,8 @@ impl Session {
idle_interrupt_receiver: Receiver<()>,
folder: &str,
) -> Result<Self> {
let transport_id = self.transport_id();
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context)? {
@@ -36,13 +38,16 @@ impl Session {
if self.new_mail {
info!(
context,
"Skipping IDLE in {folder:?} because there may be new mail."
"Transport {transport_id}: Skipping IDLE in {folder:?} because there may be new mail."
);
return Ok(self);
}
if let Ok(()) = idle_interrupt_receiver.try_recv() {
info!(context, "Skip IDLE in {folder:?} because we got interrupt.");
info!(
context,
"Transport {transport_id}: Skip IDLE in {folder:?} because we got interrupt."
);
return Ok(self);
}
@@ -61,7 +66,7 @@ impl Session {
info!(
context,
"IDLE entering wait-on-remote state in folder {folder:?}."
"Transport {transport_id}: IDLE entering wait-on-remote state in folder {folder:?}."
);
// Spawn a task to relay interrupts from `idle_interrupt_receiver`

View File

@@ -105,10 +105,9 @@ async fn check_target_folder_combination(
expected_destination: &str,
accepted_chat: bool,
outgoing: bool,
setupmessage: bool,
) -> Result<()> {
println!(
"Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"
"Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}"
);
let t = TestContext::new_alice().await;
@@ -125,9 +124,7 @@ async fn check_target_folder_combination(
}
let temp;
let bytes = if setupmessage {
include_bytes!("../../test-data/message/AutocryptSetupMessage.eml")
} else {
let bytes = {
temp = format!(
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
{}\
@@ -164,7 +161,7 @@ async fn check_target_folder_combination(
assert_eq!(
expected,
actual.as_deref(),
"For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"
"For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}: expected {expected:?}, got {actual:?}"
);
Ok(())
}
@@ -204,7 +201,6 @@ async fn test_target_folder_incoming_accepted() -> Result<()> {
expected_destination,
true,
false,
false,
)
.await?;
}
@@ -221,7 +217,6 @@ async fn test_target_folder_incoming_request() -> Result<()> {
expected_destination,
false,
false,
false,
)
.await?;
}
@@ -239,25 +234,6 @@ async fn test_target_folder_outgoing() -> Result<()> {
expected_destination,
true,
true,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_setupmsg() -> Result<()> {
// Test setupmessages
for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam"
false,
true,
true,
)
.await?;
}

View File

@@ -71,13 +71,18 @@ impl ImapSession {
self.select(folder).await
};
let transport_id = self.transport_id();
// <https://tools.ietf.org/html/rfc3501#section-6.3.1>
// says that if the server reports select failure we are in
// authenticated (not-select) state.
match res {
Ok(mailbox) => {
info!(context, "Selected folder {folder:?}.");
info!(
context,
"Transport {transport_id}: Selected folder {folder:?}."
);
self.selected_folder = Some(folder.to_string());
self.selected_mailbox = Some(mailbox);
Ok(NewlySelected::Yes)

View File

@@ -28,11 +28,9 @@ use crate::tools::{
write_file,
};
mod key_transfer;
mod transfer;
use ::pgp::types::KeyDetails;
pub use key_transfer::{continue_key_transfer, initiate_key_transfer};
pub use transfer::{BackupProvider, get_backup};
// Name of the database file in the backup.
@@ -1139,4 +1137,16 @@ mod tests {
Ok(())
}
/// Tests importing a backup from Delta Chat 1.30.3 for Android (core v1.86.0).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_import_ancient_backup() -> Result<()> {
let mut tcm = TestContextManager::new();
let context = &tcm.unconfigured().await;
let backup_path = Path::new("test-data/core-1.86.0-backup.tar");
imex(context, ImexMode::ImportBackup, backup_path, None).await?;
Ok(())
}
}

View File

@@ -1,363 +0,0 @@
//! # Key transfer via Autocrypt Setup Message.
use std::io::BufReader;
use anyhow::{Result, bail, ensure};
use crate::blob::BlobObject;
use crate::chat::{self, ChatId};
use crate::config::Config;
use crate::constants::{ASM_BODY, ASM_SUBJECT};
use crate::contact::ContactId;
use crate::context::Context;
use crate::imex::set_self_key;
use crate::key::{DcKey, load_self_secret_key};
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::param::Param;
use crate::pgp;
use crate::tools::open_file_std;
/// Initiates key transfer via Autocrypt Setup Message.
///
/// Returns setup code.
pub async fn initiate_key_transfer(context: &Context) -> Result<String> {
let setup_code = create_setup_code(context);
/* this may require a keypair to be created. this may take a second ... */
let setup_file_content = render_setup_file(context, &setup_code).await?;
/* encrypting may also take a while ... */
let setup_file_blob = BlobObject::create_and_deduplicate_from_bytes(
context,
setup_file_content.as_bytes(),
"autocrypt-setup-message.html",
)?;
let chat_id = ChatId::create_for_contact(context, ContactId::SELF).await?;
let mut msg = Message::new(Viewtype::File);
msg.param.set(Param::File, setup_file_blob.as_name());
msg.param
.set(Param::Filename, "autocrypt-setup-message.html");
msg.subject = ASM_SUBJECT.to_owned();
msg.param
.set(Param::MimeType, "application/autocrypt-setup");
msg.param.set_cmd(SystemMessage::AutocryptSetupMessage);
msg.force_plaintext();
msg.param.set_int(Param::SkipAutocrypt, 1);
// Enable BCC-self, because transferring a key
// means we have a multi-device setup.
context.set_config_bool(Config::BccSelf, true).await?;
chat::send_msg(context, chat_id, &mut msg).await?;
Ok(setup_code)
}
/// Continue key transfer via Autocrypt Setup Message.
///
/// `msg_id` is the ID of the received Autocrypt Setup Message.
/// `setup_code` is the code entered by the user.
pub async fn continue_key_transfer(
context: &Context,
msg_id: MsgId,
setup_code: &str,
) -> Result<()> {
ensure!(!msg_id.is_special(), "wrong id");
let msg = Message::load_from_db(context, msg_id).await?;
ensure!(
msg.is_setupmessage(),
"Message is no Autocrypt Setup Message."
);
if let Some(filename) = msg.get_file(context) {
let file = open_file_std(context, filename)?;
let sc = normalize_setup_code(setup_code);
let armored_key = decrypt_setup_file(&sc, BufReader::new(file)).await?;
set_self_key(context, &armored_key).await?;
context.set_config_bool(Config::BccSelf, true).await?;
Ok(())
} else {
bail!("Message is no Autocrypt Setup Message.");
}
}
/// Renders HTML body of a setup file message.
///
/// The `passphrase` must be at least 2 characters long.
pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<String> {
let passphrase_begin = if let Some(passphrase_begin) = passphrase.get(..2) {
passphrase_begin
} else {
bail!("Passphrase must be at least 2 chars long.");
};
let private_key = load_self_secret_key(context).await?;
let ac_headers = Some(("Autocrypt-Prefer-Encrypt", "mutual"));
let private_key_asc = private_key.to_asc(ac_headers);
let encr = pgp::symm_encrypt_autocrypt_setup(passphrase, private_key_asc.into_bytes())
.await?
.replace('\n', "\r\n");
let replacement = format!(
concat!(
"-----BEGIN PGP MESSAGE-----\r\n",
"Passphrase-Format: numeric9x4\r\n",
"Passphrase-Begin: {}"
),
passphrase_begin
);
let pgp_msg = encr.replace("-----BEGIN PGP MESSAGE-----", &replacement);
let msg_subj = ASM_SUBJECT;
let msg_body = ASM_BODY.to_string();
let msg_body_html = msg_body.replace('\r', "").replace('\n', "<br>");
Ok(format!(
concat!(
"<!DOCTYPE html>\r\n",
"<html>\r\n",
" <head>\r\n",
" <title>{}</title>\r\n",
" </head>\r\n",
" <body>\r\n",
" <h1>{}</h1>\r\n",
" <p>{}</p>\r\n",
" <pre>\r\n{}\r\n</pre>\r\n",
" </body>\r\n",
"</html>\r\n"
),
msg_subj, msg_subj, msg_body_html, pgp_msg
))
}
/// Creates a new setup code for Autocrypt Setup Message.
#[expect(clippy::arithmetic_side_effects)]
fn create_setup_code(_context: &Context) -> String {
let mut random_val: u16;
let mut ret = String::new();
for i in 0..9 {
loop {
random_val = rand::random();
if random_val as usize <= 60000 {
break;
}
}
random_val = (random_val as usize % 10000) as u16;
ret += &format!(
"{}{:04}",
if 0 != i { "-" } else { "" },
random_val as usize
);
}
ret
}
async fn decrypt_setup_file<T: std::fmt::Debug + std::io::BufRead + Send + 'static>(
passphrase: &str,
file: T,
) -> Result<String> {
let plain_bytes = pgp::symm_decrypt(passphrase, file).await?;
let plain_text = std::string::String::from_utf8(plain_bytes)?;
Ok(plain_text)
}
fn normalize_setup_code(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_digit() {
out.push(c);
if let 4 | 9 | 14 | 19 | 24 | 29 | 34 | 39 = out.len() {
out += "-"
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pgp::{HEADER_AUTOCRYPT, HEADER_SETUPCODE, split_armored_data};
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
use ::pgp::armor::BlockType;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_setup_file() {
let t = TestContext::new_alice().await;
let msg = render_setup_file(&t, "hello").await.unwrap();
println!("{}", &msg);
// Check some substrings, indicating things got substituted.
assert!(msg.contains("<title>Autocrypt Setup Message</title"));
assert!(msg.contains("<h1>Autocrypt Setup Message</h1>"));
assert!(msg.contains("<p>This is the Autocrypt Setup Message used to"));
assert!(msg.contains("-----BEGIN PGP MESSAGE-----\r\n"));
assert!(msg.contains("Passphrase-Format: numeric9x4\r\n"));
assert!(msg.contains("Passphrase-Begin: he\r\n"));
assert!(msg.contains("-----END PGP MESSAGE-----\r\n"));
for line in msg.rsplit_terminator('\n') {
assert!(line.ends_with('\r'));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_setup_file_newline_replace() {
let t = TestContext::new_alice().await;
let msg = render_setup_file(&t, "pw").await.unwrap();
println!("{}", &msg);
assert!(msg.contains("<p>This is the Autocrypt Setup Message used to transfer your end-to-end setup between clients.<br>"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_setup_code() {
let t = TestContext::new().await;
let setupcode = create_setup_code(&t);
assert_eq!(setupcode.len(), 44);
assert_eq!(setupcode.chars().nth(4).unwrap(), '-');
assert_eq!(setupcode.chars().nth(9).unwrap(), '-');
assert_eq!(setupcode.chars().nth(14).unwrap(), '-');
assert_eq!(setupcode.chars().nth(19).unwrap(), '-');
assert_eq!(setupcode.chars().nth(24).unwrap(), '-');
assert_eq!(setupcode.chars().nth(29).unwrap(), '-');
assert_eq!(setupcode.chars().nth(34).unwrap(), '-');
assert_eq!(setupcode.chars().nth(39).unwrap(), '-');
}
#[test]
fn test_normalize_setup_code() {
let norm = normalize_setup_code("123422343234423452346234723482349234");
assert_eq!(norm, "1234-2234-3234-4234-5234-6234-7234-8234-9234");
let norm =
normalize_setup_code("\t1 2 3422343234- foo bar-- 423-45 2 34 6234723482349234 ");
assert_eq!(norm, "1234-2234-3234-4234-5234-6234-7234-8234-9234");
}
/* S_EM_SETUPFILE is a AES-256 symm. encrypted setup message created by Enigmail
with an "encrypted session key", see RFC 4880. The code is in S_EM_SETUPCODE */
const S_EM_SETUPCODE: &str = "1742-0185-6197-1303-7016-8412-3581-4441-0597";
const S_EM_SETUPFILE: &str = include_str!("../../test-data/message/stress.txt");
// Autocrypt Setup Message payload "encrypted" with plaintext algorithm.
const S_PLAINTEXT_SETUPFILE: &str =
include_str!("../../test-data/message/plaintext-autocrypt-setup.txt");
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_split_and_decrypt() {
let buf_1 = S_EM_SETUPFILE.as_bytes().to_vec();
let (typ, headers, base64) = split_armored_data(&buf_1).unwrap();
assert_eq!(typ, BlockType::Message);
assert!(S_EM_SETUPCODE.starts_with(headers.get(HEADER_SETUPCODE).unwrap()));
assert!(!headers.contains_key(HEADER_AUTOCRYPT));
assert!(!base64.is_empty());
let setup_file = S_EM_SETUPFILE;
let decrypted = decrypt_setup_file(S_EM_SETUPCODE, setup_file.as_bytes())
.await
.unwrap();
let (typ, headers, _base64) = split_armored_data(decrypted.as_bytes()).unwrap();
assert_eq!(typ, BlockType::PrivateKey);
assert_eq!(headers.get(HEADER_AUTOCRYPT), Some(&"mutual".to_string()));
assert!(!headers.contains_key(HEADER_SETUPCODE));
}
/// Tests that Autocrypt Setup Message encrypted with "plaintext" algorithm cannot be
/// decrypted.
///
/// According to <https://datatracker.ietf.org/doc/html/rfc4880#section-13.4>
/// "Implementations MUST NOT use plaintext in Symmetrically Encrypted Data packets".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decrypt_plaintext_autocrypt_setup_message() {
let setup_file = S_PLAINTEXT_SETUPFILE;
let incorrect_setupcode = "0000-0000-0000-0000-0000-0000-0000-0000-0000";
assert!(
decrypt_setup_file(incorrect_setupcode, setup_file.as_bytes(),)
.await
.is_err()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_key_transfer() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
tcm.section("Alice sends Autocrypt setup message");
alice.set_config(Config::BccSelf, Some("0")).await?;
let setup_code = initiate_key_transfer(alice).await?;
// Test that sending Autocrypt Setup Message enables `bcc_self`.
assert_eq!(alice.get_config_bool(Config::BccSelf).await?, true);
// Get Autocrypt Setup Message.
let sent = alice.pop_sent_msg().await;
tcm.section("Alice sets up a second device");
let alice2 = &tcm.unconfigured().await;
alice2.set_name("alice2");
alice2.configure_addr("alice@example.org").await;
alice2.recv_msg(&sent).await;
let msg = alice2.get_last_msg().await;
assert!(msg.is_setupmessage());
assert_eq!(crate::key::load_self_secret_keyring(alice2).await?.len(), 0);
// Transfer the key.
tcm.section("Alice imports a key from Autocrypt Setup Message");
alice2.set_config(Config::BccSelf, Some("0")).await?;
continue_key_transfer(alice2, msg.id, &setup_code).await?;
assert_eq!(alice2.get_config_bool(Config::BccSelf).await?, true);
assert_eq!(crate::key::load_self_secret_keyring(alice2).await?.len(), 1);
// Alice sends a message to self from the new device.
let sent = alice2.send_text(msg.chat_id, "Test").await;
let rcvd_msg = alice.recv_msg(&sent).await;
assert_eq!(rcvd_msg.get_text(), "Test");
Ok(())
}
/// Tests that Autocrypt Setup Messages is only clickable if it is self-sent.
/// This prevents Bob from tricking Alice into changing the key
/// by sending her an Autocrypt Setup Message as long as Alice's server
/// does not allow to forge the `From:` header.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_key_transfer_non_self_sent() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let _setup_code = initiate_key_transfer(&alice).await?;
// Get Autocrypt Setup Message.
let sent = alice.pop_sent_msg().await;
let rcvd = bob.recv_msg(&sent).await;
assert!(!rcvd.is_setupmessage());
Ok(())
}
/// Tests reception of Autocrypt Setup Message from K-9 6.802.
///
/// Unlike Autocrypt Setup Message sent by Delta Chat,
/// this message does not contain `Autocrypt-Prefer-Encrypt` header.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_key_transfer_k_9() -> Result<()> {
let t = &TestContext::new().await;
t.configure_addr("autocrypt@nine.testrun.org").await;
let raw = include_bytes!("../../test-data/message/k-9-autocrypt-setup-message.eml");
let received = receive_imf(t, raw, false).await?.unwrap();
let setup_code = "0655-9868-8252-5455-4232-5158-1237-5333-2638";
continue_key_transfer(t, *received.msg_ids.last().unwrap(), setup_code).await?;
Ok(())
}
}

View File

@@ -208,7 +208,7 @@ impl BackupProvider {
info!(context, "Received backup reception acknowledgement.");
context.emit_event(EventType::ImexProgress(1000));
let mut msg = Message::new_text(backup_transfer_msg_body(&context).await);
let mut msg = Message::new_text(backup_transfer_msg_body(&context));
add_device_msg(&context, None, Some(&mut msg)).await?;
Ok(())

View File

@@ -296,7 +296,7 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
.await?
.context("No transports configured")?;
let addr = context.get_primary_self_addr().await?;
let all_addrs = context.get_all_self_addrs().await?.join(",");
let all_addrs = context.get_published_self_addrs().await?.join(",");
let signed_public_key =
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
*lock = Some(signed_public_key.clone());
@@ -756,8 +756,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
/// this resulted in various number of garbage
/// octets at the end of the key, starting from 3 octets,
/// but possibly 4 or 5 and maybe more octets
/// if the key is imported or transferred
/// using Autocrypt Setup Message multiple times.
/// if the key is imported multiple times.
#[test]
fn test_ignore_trailing_garbage() {
// Test several variants of garbage.

View File

@@ -288,13 +288,13 @@ pub async fn send_locations_to_chat(
)
.await?;
if 0 != seconds && !is_sending_locations_before {
let mut msg = Message::new_text(stock_str::msg_location_enabled(context).await);
let mut msg = Message::new_text(stock_str::msg_location_enabled(context));
msg.param.set_cmd(SystemMessage::LocationStreamingEnabled);
chat::send_msg(context, chat_id, &mut msg)
.await
.unwrap_or_default();
} else if 0 == seconds && is_sending_locations_before {
let stock_str = stock_str::msg_location_disabled(context).await;
let stock_str = stock_str::msg_location_disabled(context);
chat::add_info_msg(context, chat_id, &stock_str).await?;
}
context.emit_event(EventType::ChatModified(chat_id));
@@ -852,7 +852,7 @@ async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
.await
.context("failed to disable location streaming")?;
let stock_str = stock_str::msg_location_disabled(context).await;
let stock_str = stock_str::msg_location_disabled(context);
chat::add_info_msg(context, chat_id, &stock_str).await?;
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);

View File

@@ -79,6 +79,16 @@ pub struct EnteredServerLoginParam {
pub password: String,
}
/// A transport, as shown in the "relays" list in the UI.
#[derive(Debug)]
pub struct TransportListEntry {
/// The login data entered by the user.
pub param: EnteredLoginParam,
/// Whether this transport is set to 'unpublished'.
/// See [`Context::set_transport_unpublished`] for details.
pub is_unpublished: bool,
}
/// Login parameters entered by the user.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnteredLoginParam {

View File

@@ -30,7 +30,6 @@ use crate::location::delete_poi_location;
use crate::log::warn;
use crate::mimeparser::{SystemMessage, parse_message_id};
use crate::param::{Param, Params};
use crate::pgp::split_armored_data;
use crate::reaction::get_msg_reactions;
use crate::sql;
use crate::summary::Summary;
@@ -599,7 +598,7 @@ impl Message {
if let Some(msg) = &mut msg {
msg.additional_text =
Self::get_additional_text(context, msg.download_state, &msg.param).await?;
Self::get_additional_text(context, msg.download_state, &msg.param)?;
}
Ok(msg)
@@ -608,7 +607,7 @@ impl Message {
/// Returns additional text which is appended to the message's text field
/// when it is loaded from the database.
/// Currently this is used to add infomation to pre-messages of what the download will be and how large it is
async fn get_additional_text(
fn get_additional_text(
context: &Context,
download_state: DownloadState,
param: &Params,
@@ -631,7 +630,7 @@ impl Message {
return match viewtype {
Viewtype::File => Ok(format!(" [{file_name} {file_size}]")),
_ => {
let translated_viewtype = viewtype.to_locale_string(context).await;
let translated_viewtype = viewtype.to_locale_string(context);
Ok(format!(" [{translated_viewtype} {file_size}]"))
}
};
@@ -816,7 +815,11 @@ impl Message {
/// Returns the timestamp of the message for sorting.
pub fn get_sort_timestamp(&self) -> i64 {
self.timestamp_sort
if self.timestamp_sort != 0 {
self.timestamp_sort
} else {
self.timestamp_sent
}
}
/// Returns the text of the message.
@@ -1059,34 +1062,6 @@ impl Message {
cmd != SystemMessage::Unknown
}
/// Returns true if the message is an Autocrypt Setup Message.
pub fn is_setupmessage(&self) -> bool {
if self.viewtype != Viewtype::File {
return false;
}
self.param.get_cmd() == SystemMessage::AutocryptSetupMessage
}
/// Returns the first characters of the setup code.
///
/// This is used to pre-fill the first entry field of the setup code.
pub async fn get_setupcodebegin(&self, context: &Context) -> Option<String> {
if !self.is_setupmessage() {
return None;
}
if let Some(filename) = self.get_file(context)
&& let Ok(ref buf) = read_file(context, &filename).await
&& let Ok((typ, headers, _)) = split_armored_data(buf)
&& typ == pgp::armor::BlockType::Message
{
return headers.get(crate::pgp::HEADER_SETUPCODE).cloned();
}
None
}
/// Sets or unsets message text.
pub fn set_text(&mut self, text: String) {
self.text = text;
@@ -1934,10 +1909,22 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
// We also don't send read receipts for contact requests.
// Read receipts will not be sent even after accepting the chat.
let to_id = if curr_blocked == Blocked::Not
&& !curr_hidden
&& curr_param.get_bool(Param::WantsMdn).unwrap_or_default()
&& curr_param.get_cmd() == SystemMessage::Unknown
&& context.should_send_mdns().await?
{
// Clear WantsMdn to not handle a MDN twice
// if the state later is InFresh again as markfresh_chat() was called.
// BccSelf MDN messages in the next branch may be sent twice for syncing.
context
.sql
.execute(
"UPDATE msgs SET param=? WHERE id=?",
(curr_param.clone().remove(Param::WantsMdn).to_string(), id),
)
.await
.context("failed to clear WantsMdn")?;
Some(curr_from_id)
} else if context.get_config_bool(Config::BccSelf).await? {
Some(ContactId::SELF)
@@ -1955,6 +1942,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
.context("failed to insert into smtp_mdns")?;
context.scheduler.interrupt_smtp().await;
}
if !curr_hidden {
updated_chat_ids.insert(curr_chat_id);
}

View File

@@ -17,8 +17,7 @@ use crate::aheader::{Aheader, EncryptPreference};
use crate::blob::BlobObject;
use crate::chat::{self, Chat, PARAM_BROADCAST_SECRET, load_broadcast_secret};
use crate::config::Config;
use crate::constants::{ASM_SUBJECT, BROADCAST_INCOMPATIBILITY_MSG};
use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
use crate::constants::{BROADCAST_INCOMPATIBILITY_MSG, Chattype, DC_FROM_HANDSHAKE};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::download::PostMsgMetadata;
@@ -33,7 +32,7 @@ use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{SystemMessage, is_hidden};
use crate::param::Param;
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
use crate::pgp::{SeipdVersion, addresses_from_public_key};
use crate::pgp::{SeipdVersion, addresses_from_public_key, pubkey_supports_seipdv2};
use crate::simplify::escape_message_footer_marks;
use crate::stock_str;
use crate::tools::{
@@ -733,7 +732,7 @@ impl MimeFactory {
Some(name) => name,
None => context.get_config(Config::Addr).await?.unwrap_or_default(),
};
stock_str::subject_for_new_contact(context, self_name).await
stock_str::subject_for_new_contact(context, self_name)
}
Loaded::Mdn { .. } => "Receipt Notification".to_string(), // untranslated to no reveal sender's language
};
@@ -853,7 +852,13 @@ impl MimeFactory {
let rfc724_mid = match &self.loaded {
Loaded::Message { msg, .. } => match &self.pre_message_mode {
PreMessageMode::Pre { .. } => create_outgoing_rfc724_mid(),
PreMessageMode::Pre { .. } => {
if msg.pre_rfc724_mid.is_empty() {
create_outgoing_rfc724_mid()
} else {
msg.pre_rfc724_mid.clone()
}
}
_ => msg.rfc724_mid.clone(),
},
Loaded::Mdn { .. } => create_outgoing_rfc724_mid(),
@@ -1163,17 +1168,6 @@ impl MimeFactory {
_ => None,
};
// Do not anonymize OpenPGP recipients.
//
// This is disabled to avoid interoperability problems
// with old core versions <1.160.0 that do not support
// receiving messages with wildcard Key IDs:
// <https://github.com/chatmail/core/issues/7378>
//
// The option should be changed to true
// once new core versions are sufficiently deployed.
let anonymous_recipients = false;
if context.get_config_bool(Config::TestHooks).await?
&& let Some(hook) = &*context.pre_encrypt_mime_hook.lock()
{
@@ -1188,14 +1182,13 @@ impl MimeFactory {
} else {
// Asymmetric encryption
let seipd_version = if encryption_pubkeys.is_empty() {
// If message is sent only to self,
// use v2 SEIPD.
// Use SEIPDv2 if all recipients support it.
let seipd_version = if encryption_pubkeys
.iter()
.all(|(_addr, pubkey)| pubkey_supports_seipdv2(pubkey))
{
SeipdVersion::V2
} else {
// If message is sent to others,
// they may not support v2 SEIPD yet,
// so use v1 SEIPD.
SeipdVersion::V1
};
@@ -1211,7 +1204,6 @@ impl MimeFactory {
encryption_keyring,
message,
compress,
anonymous_recipients,
seipd_version,
)
.await?
@@ -1538,7 +1530,7 @@ impl MimeFactory {
let description = chat::get_chat_description(context, chat.id).await?;
headers.push((
"Chat-Group-Description",
mail_builder::headers::text::Text::new(description.clone()).into(),
mail_builder::headers::raw::Raw::new(b_encode(&description)).into(),
));
if let Some(ts) = chat.param.get_i64(Param::GroupDescriptionTimestamp) {
headers.push((
@@ -1575,14 +1567,6 @@ impl MimeFactory {
mail_builder::headers::raw::Raw::new("auto-generated").into(),
));
}
SystemMessage::AutocryptSetupMessage => {
headers.push((
"Autocrypt-Setup-Message",
mail_builder::headers::raw::Raw::new("v1").into(),
));
placeholdertext = Some(ASM_SUBJECT.to_string());
}
SystemMessage::SecurejoinMessage => {
let step = msg.param.get(Param::Arg).unwrap_or_default();
if !step.is_empty() {

View File

@@ -1,6 +1,9 @@
use deltachat_contact_tools::ContactAddress;
use mail_builder::headers::Header;
use mailparse::{MailHeaderMap, addrparse_header};
use pgp::armor;
use pgp::packet::{Packet, PacketParser};
use std::io::BufReader;
use std::str;
use std::time::Duration;
@@ -11,7 +14,7 @@ use crate::chat::{
};
use crate::chatlist::Chatlist;
use crate::constants;
use crate::contact::Origin;
use crate::contact::{Origin, import_vcard};
use crate::headerdef::HeaderDef;
use crate::message;
use crate::mimeparser::MimeMessage;
@@ -877,3 +880,85 @@ async fn test_no_empty_to_header() -> Result<()> {
Ok(())
}
/// Parses ASCII-armored message and checks that it only has PKESK and SEIPD packets.
///
/// Panics if SEIPD packets are not of expected version.
fn assert_seipd_version(payload: &str, version: usize) {
let cursor = Cursor::new(payload);
let dearmor = armor::Dearmor::new(cursor);
let packet_parser = PacketParser::new(BufReader::new(dearmor));
for packet in packet_parser {
match packet.unwrap() {
Packet::PublicKeyEncryptedSessionKey(_pkesk) => {}
Packet::SymEncryptedProtectedData(seipd) => {
assert_eq!(seipd.version(), version);
}
packet => {
panic!("Unexpected packet {:?}", packet);
}
}
}
}
/// Tests that messages between two test accounts use SEIPDv2 and not SEIPDv1.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_use_seipdv2() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = alice.create_chat_id(bob).await;
let sent = alice.send_text(alice_chat_id, "Hello!").await;
assert_seipd_version(&sent.payload, 2);
Ok(())
}
/// Tests that messages to keys that don't advertise SEIPDv2 support
/// are sent using SEIPDv1.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fallback_to_seipdv1() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
// vCard of Alice with no SEIPDv2 feature advertised in the key.
let alice_vcard = "BEGIN:VCARD
VERSION:4.0
EMAIL:alice@example.org
FN:Alice
KEY:data:application/pgp-keys;base64,mDMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5C0GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz6IkAQTFggAOBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEGSwj2Gp7ZRDE3oA/i4MCyDMTsjWqDZoQwX/A/GoTO2/V0wKPhjJJy/8m2pMAPkBjOnGOtx2SZpQvJGTa9h804RY6iDrRuI8A/8tEEXAA7g4BF5Ydd0SCisGAQQBl1UBBQEBB0AG7cjWy2SFAU8KnltlubVW67rFiyfp01JrRe6Xqy22HQMBCAeIeAQYFggAIBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsMAAoJEGSwj2Gp7ZRDLo8BAObE8GnsGVwKzNqCvHeWgJsqhjS3C6gvSlV3tEm9XmF6AQDXucIyVfoBwoyMh2h6cSn/ATn5QJb35pgo+ivp3jsMAg==
REV:20250412T195751Z
END:VCARD";
let contact_ids = import_vcard(bob, alice_vcard).await.unwrap();
let alice_contact_id = contact_ids[0];
let chat_id = ChatId::create_for_contact(bob, alice_contact_id)
.await
.unwrap();
// Bob sends a message to Alice with SEIPDv1 packet.
let sent = bob.send_text(chat_id, "Hello!").await;
assert_seipd_version(&sent.payload, 1);
// Bob creates a group with Alice and Charlie.
// Sending a message there should also use SEIPDv1
// because for Bob it looks like Alice does not support SEIPDv2.
let charlie_contact_id = bob.add_or_lookup_contact_id(charlie).await;
let group_id = create_group(bob, "groupname").await.unwrap();
chat::add_contact_to_chat(bob, group_id, alice_contact_id).await?;
chat::add_contact_to_chat(bob, group_id, charlie_contact_id).await?;
let sent = bob.send_text(group_id, "Hello!").await;
assert_seipd_version(&sent.payload, 1);
// Bob gets a new key of Alice via new vCard
// and learns that Alice supports SEIPDv2.
assert_eq!(bob.add_or_lookup_contact_id(alice).await, alice_contact_id);
let sent = bob.send_text(group_id, "Hello again with SEIPDv2!").await;
assert_seipd_version(&sent.payload, 2);
Ok(())
}

View File

@@ -86,7 +86,9 @@ pub(crate) struct MimeMessage {
/// messages to this address to post them to the list.
pub list_post: Option<String>,
pub chat_disposition_notification_to: Option<SingleInfo>,
pub decrypting_failed: bool,
/// Decryption error if decryption of the message has failed.
pub decryption_error: Option<String>,
/// Valid signature fingerprint if a message is an
/// Autocrypt encrypted and signed message and corresponding intended recipient fingerprints
@@ -199,6 +201,9 @@ pub enum SystemMessage {
MemberRemovedFromGroup = 5,
/// Autocrypt Setup Message.
///
/// Deprecated as of 2026-03-15, such messages should not be created
/// but may exist in the database.
AutocryptSetupMessage = 6,
/// Secure-join message.
@@ -259,8 +264,6 @@ pub enum SystemMessage {
GroupDescriptionChanged = 70,
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
impl MimeMessage {
/// Parse a mime message.
///
@@ -370,7 +373,7 @@ impl MimeMessage {
hop_info += "\n\n";
hop_info += &dkim_results.to_string();
let incoming = !context.is_self_addr(&from.addr).await?;
let from_is_not_self_addr = !context.is_self_addr(&from.addr).await?;
let mut aheader_values = mail.headers.get_all_values(HeaderDef::Autocrypt.into());
@@ -435,7 +438,7 @@ impl MimeMessage {
};
let mut autocrypt_header = None;
if incoming {
if from_is_not_self_addr {
// See `get_all_addresses_from_header()` for why we take the last valid header.
for val in aheader_values.iter().rev() {
autocrypt_header = match Aheader::from_str(val) {
@@ -466,7 +469,7 @@ impl MimeMessage {
None
};
let mut public_keyring = if incoming {
let mut public_keyring = if from_is_not_self_addr {
if let Some(autocrypt_header) = autocrypt_header {
vec![autocrypt_header.public_key]
} else {
@@ -651,6 +654,15 @@ impl MimeMessage {
.into_iter()
.last()
.map(|(fp, recipient_fps)| (fp, recipient_fps.into_iter().collect::<HashSet<_>>()));
let incoming = if let Some((ref sig_fp, _)) = signature {
sig_fp.hex() != key::self_fingerprint(context).await?
} else {
// rare case of getting a cleartext message
// so we determine 'incoming' flag by From-address
from_is_not_self_addr
};
let mut parser = MimeMessage {
parts: Vec::new(),
headers,
@@ -663,7 +675,7 @@ impl MimeMessage {
from,
incoming,
chat_disposition_notification_to,
decrypting_failed: mail.is_err(),
decryption_error: mail.err().map(|err| format!("{err:#}")),
// only non-empty if it was a valid autocrypt message
signature,
@@ -742,20 +754,8 @@ impl MimeMessage {
}
/// Parses system messages.
fn parse_system_message_headers(&mut self, context: &Context) {
if self.get_header(HeaderDef::AutocryptSetupMessage).is_some() && !self.incoming {
self.parts.retain(|part| {
part.mimetype
.as_ref()
.is_none_or(|mimetype| mimetype.as_ref() == MIME_AC_SETUP_FILE)
});
if self.parts.len() == 1 {
self.is_system_message = SystemMessage::AutocryptSetupMessage;
} else {
warn!(context, "could not determine ASM mime-part");
}
} else if let Some(value) = self.get_header(HeaderDef::ChatContent) {
fn parse_system_message_headers(&mut self) {
if let Some(value) = self.get_header(HeaderDef::ChatContent) {
if value == "location-streaming-enabled" {
self.is_system_message = SystemMessage::LocationStreamingEnabled;
} else if value == "ephemeral-timer-changed" {
@@ -905,7 +905,7 @@ impl MimeMessage {
}
async fn parse_headers(&mut self, context: &Context) -> Result<()> {
self.parse_system_message_headers(context);
self.parse_system_message_headers();
self.parse_avatar_headers(context)?;
self.parse_videochat_headers();
if self.delivery_report.is_none() {
@@ -916,7 +916,7 @@ impl MimeMessage {
&& let Some(ref subject) = self.get_subject()
{
let mut prepend_subject = true;
if !self.decrypting_failed {
if self.decryption_error.is_none() {
let colon = subject.find(':');
if colon == Some(2)
|| colon == Some(3)
@@ -957,7 +957,7 @@ impl MimeMessage {
self.parse_attachments();
// See if an MDN is requested from the other side
if !self.decrypting_failed
if self.decryption_error.is_none()
&& !self.parts.is_empty()
&& let Some(ref dn_to) = self.chat_disposition_notification_to
{
@@ -1089,7 +1089,7 @@ impl MimeMessage {
#[cfg(test)]
/// Returns whether the decrypted data contains the given `&str`.
pub(crate) fn decoded_data_contains(&self, s: &str) -> bool {
assert!(!self.decrypting_failed);
assert!(self.decryption_error.is_none());
let decoded_str = str::from_utf8(&self.decoded_data).unwrap();
decoded_str.contains(s)
}

View File

@@ -204,7 +204,7 @@ async fn test_qr_code_security() -> Result<()> {
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
let alice_fp = self_fingerprint(alice).await?;
let secret_for_encryption = dbg!(format!("securejoin/{alice_fp}/{authcode}"));
let secret_for_encryption = format!("securejoin/{alice_fp}/{authcode}");
test_shared_secret_decryption_ex(
bob,
&charlie_addr,

View File

@@ -881,7 +881,7 @@ fn merge_with_cache(
) -> Vec<SocketAddr> {
let rest = resolved_addrs.split_off(std::cmp::min(resolved_addrs.len(), 2));
for addr in cache.into_iter().chain(rest.into_iter()) {
for addr in cache.into_iter().chain(rest) {
if !resolved_addrs.contains(&addr) {
resolved_addrs.push(addr);
if resolved_addrs.len() >= 10 {

View File

@@ -7,8 +7,12 @@ use anyhow::Result;
use crate::net::session::SessionStream;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::ClientSessionStore;
mod danger;
use danger::NoCertificateVerification;
pub async fn wrap_tls<'a>(
strict_tls: bool,
hostname: &str,
@@ -82,7 +86,7 @@ impl TlsSessionStore {
.lock()
.entry((port, alpn.to_string()))
.or_insert_with(|| {
Arc::new(tokio_rustls::rustls::client::ClientSessionMemoryCache::new(
Arc::new(rustls::client::ClientSessionMemoryCache::new(
TLS_CACHE_SIZE,
))
}),
@@ -98,10 +102,10 @@ pub async fn wrap_rustls<'a>(
stream: impl SessionStream + 'a,
tls_session_store: &TlsSessionStore,
) -> Result<impl SessionStream + 'a> {
let mut root_cert_store = tokio_rustls::rustls::RootCertStore::empty();
let mut root_cert_store = rustls::RootCertStore::empty();
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config = tokio_rustls::rustls::ClientConfig::builder()
let mut config = rustls::ClientConfig::builder()
.with_root_certificates(root_cert_store)
.with_no_client_auth();
config.alpn_protocols = if alpn.is_empty() {
@@ -118,13 +122,25 @@ pub async fn wrap_rustls<'a>(
// and are not worth increasing
// attack surface: <https://words.filippo.io/we-need-to-talk-about-session-tickets/>.
let resumption_store = tls_session_store.get(port, alpn);
let resumption = tokio_rustls::rustls::client::Resumption::store(resumption_store)
.tls12_resumption(tokio_rustls::rustls::client::Tls12Resumption::Disabled);
let resumption = rustls::client::Resumption::store(resumption_store)
.tls12_resumption(rustls::client::Tls12Resumption::Disabled);
config.resumption = resumption;
config.enable_sni = use_sni;
// Do not verify certificates for hostnames starting with `_`.
// They are used for servers with self-signed certificates, e.g. for local testing.
// Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates.
// It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements
// explicitly state that domains should start with a letter, digit or hyphen:
// https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md
if hostname.starts_with("_") {
config
.dangerous()
.set_certificate_verifier(Arc::new(NoCertificateVerification::new()));
}
let tls = tokio_rustls::TlsConnector::from(Arc::new(config));
let name = rustls_pki_types::ServerName::try_from(hostname)?.to_owned();
let name = tokio_rustls::rustls::pki_types::ServerName::try_from(hostname)?.to_owned();
let tls_stream = tls.connect(name, stream).await?;
Ok(tls_stream)
}

55
src/net/tls/danger.rs Normal file
View File

@@ -0,0 +1,55 @@
//! Dangerous TLS implementation of accepting invalid certificates for Rustls.
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls;
#[derive(Debug)]
pub(super) struct NoCertificateVerification();
impl NoCertificateVerification {
pub(super) fn new() -> Self {
Self()
}
}
impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
let provider = rustls::crypto::ring::default_provider();
let supported_schemes = &provider.signature_verification_algorithms;
rustls::crypto::verify_tls12_signature(message, cert, dss, supported_schemes)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
let provider = rustls::crypto::ring::default_provider();
let supported_schemes = &provider.signature_verification_algorithms;
rustls::crypto::verify_tls13_signature(message, cert, dss, supported_schemes)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
let provider = rustls::crypto::ring::default_provider();
provider
.signature_verification_algorithms
.supported_schemes()
}
}

View File

@@ -1,15 +1,14 @@
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{BufRead, Cursor};
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use anyhow::{Context as _, Result, ensure};
use deltachat_contact_tools::{EmailAddress, may_be_valid_addr};
use pgp::armor::BlockType;
use pgp::composed::{
ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType as PgpKeyType,
Message, MessageBuilder, SecretKeyParamsBuilder, SignedKeyDetails, SignedPublicKey,
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
MessageBuilder, SecretKeyParamsBuilder, SignedKeyDetails, SignedPublicKey, SignedPublicSubKey,
SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
};
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
use pgp::crypto::ecc_curve::ECCCurve;
@@ -26,45 +25,9 @@ use tokio::runtime::Handle;
use crate::key::{DcKey, Fingerprint};
#[cfg(test)]
pub(crate) const HEADER_AUTOCRYPT: &str = "autocrypt-prefer-encrypt";
pub(crate) const HEADER_SETUPCODE: &str = "passphrase-begin";
/// Preferred symmetric encryption algorithm.
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
/// Split data from PGP Armored Data as defined in <https://tools.ietf.org/html/rfc4880#section-6.2>.
///
/// Returns (type, headers, base64 encoded body).
pub fn split_armored_data(buf: &[u8]) -> Result<(BlockType, BTreeMap<String, String>, Vec<u8>)> {
use std::io::Read;
let cursor = Cursor::new(buf);
let mut dearmor = pgp::armor::Dearmor::new(cursor);
let mut bytes = Vec::with_capacity(buf.len());
dearmor.read_to_end(&mut bytes)?;
let typ = dearmor.typ.context("failed to parse type")?;
// normalize headers
let headers = dearmor
.headers
.into_iter()
.map(|(key, values)| {
(
key.trim().to_lowercase(),
values
.last()
.map_or_else(String::new, |s| s.trim().to_string()),
)
})
.collect();
Ok((typ, headers, bytes))
}
/// Create a new key pair.
///
/// Both secret and public key consist of signing primary key and encryption subkey
@@ -151,7 +114,6 @@ pub async fn pk_encrypt(
public_keys_for_encryption: Vec<SignedPublicKey>,
private_key_for_signing: SignedSecretKey,
compress: bool,
anonymous_recipients: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
Handle::current()
@@ -166,13 +128,7 @@ pub async fn pk_encrypt(
hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime(
pgp::types::Timestamp::now(),
))?);
// Test "elena" uses old Delta Chat.
let skip = private_key_for_signing.dc_fingerprint().hex()
== "B86586B6DEF437D674BFAFC02A6B2EBC633B9E82";
for key in &public_keys_for_encryption {
if skip {
break;
}
let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint());
let subpkt = match private_key_for_signing.version() < KeyVersion::V6 {
true => Subpacket::regular(data)?,
@@ -198,11 +154,7 @@ pub async fn pk_encrypt(
let mut msg = msg.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM);
for pkey in pkeys {
if anonymous_recipients {
msg.encrypt_to_key_anonymous(&mut rng, &pkey)?;
} else {
msg.encrypt_to_key(&mut rng, &pkey)?;
}
msg.encrypt_to_key_anonymous(&mut rng, &pkey)?;
}
let hash_algorithm = private_key_for_signing.hash_alg();
@@ -227,11 +179,7 @@ pub async fn pk_encrypt(
);
for pkey in pkeys {
if anonymous_recipients {
msg.encrypt_to_key_anonymous(&mut rng, &pkey)?;
} else {
msg.encrypt_to_key(&mut rng, &pkey)?;
}
msg.encrypt_to_key_anonymous(&mut rng, &pkey)?;
}
let hash_algorithm = private_key_for_signing.hash_alg();
@@ -344,24 +292,6 @@ pub fn pk_validate(
Ok(ret)
}
/// Symmetric encryption for the autocrypt setup message (ASM).
pub async fn symm_encrypt_autocrypt_setup(passphrase: &str, plain: Vec<u8>) -> Result<String> {
let passphrase = Password::from(passphrase.to_string());
tokio::task::spawn_blocking(move || {
let mut rng = thread_rng();
let s2k = StringToKey::new_default(&mut rng);
let builder = MessageBuilder::from_bytes("", plain);
let mut builder = builder.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM);
builder.encrypt_with_password(s2k, &passphrase)?;
let encoded_msg = builder.to_armored_string(&mut rng, Default::default())?;
Ok(encoded_msg)
})
.await?
}
/// Symmetrically encrypt the message.
/// This is used for broadcast channels and for version 2 of the Securejoin protocol.
/// `shared secret` is the secret that will be used for symmetric encryption.
@@ -405,23 +335,6 @@ pub async fn symm_encrypt_message(
.await?
}
/// Symmetric decryption.
pub async fn symm_decrypt<T: BufRead + std::fmt::Debug + 'static + Send>(
passphrase: &str,
ctext: T,
) -> Result<Vec<u8>> {
let passphrase = passphrase.to_string();
tokio::task::spawn_blocking(move || {
let (enc_msg, _) = Message::from_armor(ctext)?;
let password = Password::from(passphrase);
let msg = enc_msg.decrypt_with_password(&password)?;
let res = msg.decompress()?.as_data_vec()?;
Ok(res)
})
.await?
}
/// Merges and minimizes OpenPGP certificates.
///
/// Keeps at most one direct key signature and
@@ -582,6 +495,35 @@ pub(crate) fn addresses_from_public_key(public_key: &SignedPublicKey) -> Option<
None
}
/// Returns true if public key advertises SEIPDv2 feature.
pub(crate) fn pubkey_supports_seipdv2(public_key: &SignedPublicKey) -> bool {
// If any Direct Key Signature or any User ID signature has SEIPDv2 feature,
// assume that recipient can handle SEIPDv2.
//
// Third-party User ID signatures are dropped during certificate merging.
// We don't check if the User ID is primary User ID.
// Primary User ID is preferred during merging
// and if some key has only non-primary User ID
// it is acceptable. It is anyway unlikely that SEIPDv2
// is advertised in a key without DKS or primary User ID.
public_key
.details
.direct_signatures
.iter()
.chain(
public_key
.details
.users
.iter()
.flat_map(|user| user.signatures.iter()),
)
.any(|signature| {
signature
.features()
.is_some_and(|features| features.seipd_v2())
})
}
#[cfg(test)]
mod tests {
use std::sync::LazyLock;
@@ -596,7 +538,7 @@ mod tests {
test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair},
token,
};
use pgp::composed::Esk;
use pgp::composed::{Esk, Message};
use pgp::packet::PublicKeyEncryptedSessionKey;
async fn decrypt_bytes(
@@ -641,33 +583,6 @@ mod tests {
Ok((msg, ret_signature_fingerprints, content))
}
#[test]
fn test_split_armored_data_1() {
let (typ, _headers, base64) = split_armored_data(
b"-----BEGIN PGP MESSAGE-----\nNoVal:\n\naGVsbG8gd29ybGQ=\n-----END PGP MESSAGE-----",
)
.unwrap();
assert_eq!(typ, BlockType::Message);
assert!(!base64.is_empty());
assert_eq!(
std::string::String::from_utf8(base64).unwrap(),
"hello world"
);
}
#[test]
fn test_split_armored_data_2() {
let (typ, headers, base64) = split_armored_data(
b"-----BEGIN PGP PRIVATE KEY BLOCK-----\nAutocrypt-Prefer-Encrypt: mutual \n\naGVsbG8gd29ybGQ=\n-----END PGP PRIVATE KEY BLOCK-----"
)
.unwrap();
assert_eq!(typ, BlockType::PrivateKey);
assert!(!base64.is_empty());
assert_eq!(headers.get(HEADER_AUTOCRYPT), Some(&"mutual".to_string()));
}
#[test]
fn test_create_keypair() {
let keypair0 = create_keypair(EmailAddress::new("foo@bar.de").unwrap()).unwrap();
@@ -707,7 +622,6 @@ mod tests {
/// A ciphertext encrypted to Alice & Bob, signed by Alice.
async fn ctext_signed() -> &'static String {
let anonymous_recipients = true;
CTEXT_SIGNED
.get_or_init(|| async {
let keyring = vec![KEYS.alice_public.clone(), KEYS.bob_public.clone()];
@@ -718,7 +632,6 @@ mod tests {
keyring,
KEYS.alice_secret.clone(),
compress,
anonymous_recipients,
SeipdVersion::V2,
)
.await
@@ -905,12 +818,12 @@ mod tests {
let pk_for_encryption = load_self_public_key(alice).await?;
// Encrypt a message, but only to self, not to Bob:
let compress = true;
let ctext = pk_encrypt(
plain,
vec![pk_for_encryption],
KEYS.alice_secret.clone(),
true,
true,
compress,
SeipdVersion::V2,
)
.await?;

View File

@@ -136,6 +136,7 @@ impl PushSubscriber {
return Ok(());
};
info!(context, "Subscribing for heartbeat notifications.");
if http::post_string(
context,
"https://notifications.delta.chat/register",
@@ -143,6 +144,7 @@ impl PushSubscriber {
)
.await?
{
info!(context, "Subscribed for heartbeat notifications.");
state.heartbeat_subscribed = true;
}
Ok(())

View File

@@ -682,6 +682,12 @@ fn decode_account(qr: &str) -> Result<Qr> {
let payload = qr
.get(DCACCOUNT_SCHEME.len()..)
.context("Invalid DCACCOUNT payload")?;
// Handle `dcaccount://...` URLs.
let payload = payload.strip_prefix("//").unwrap_or(payload);
if payload.is_empty() {
bail!("dcaccount payload is empty");
}
if payload.starts_with("https://") {
let url = url::Url::parse(payload).context("Invalid account URL")?;
if url.scheme() == "https" {
@@ -695,6 +701,12 @@ fn decode_account(qr: &str) -> Result<Qr> {
bail!("Bad scheme for account URL: {:?}.", url.scheme());
}
} else {
if payload.starts_with("/") {
// Handle `dcaccount:///` URL reported to have been created
// by Telegram link parser at
// <https://support.delta.chat/t/could-not-find-dns-resolutions-for-imap-993-when-adding-a-relay/4907>
bail!("Hostname in dcaccount URL cannot start with /");
}
Ok(Qr::Account {
domain: payload.to_string(),
})

View File

@@ -721,7 +721,9 @@ async fn test_decode_account() -> Result<()> {
for text in [
"DCACCOUNT:example.org",
"DCACCOUNT://example.org",
"dcaccount:example.org",
"dcaccount://example.org",
"DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
"dcaccount:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
] {
@@ -737,6 +739,21 @@ async fn test_decode_account() -> Result<()> {
Ok(())
}
/// Tests that decoding empty `dcaccount://` URL results in an error.
/// We should not suggest trying to configure an account in this case.
/// Such links may be created by copy-paste error or because of incorrect parsing.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_empty_account() -> Result<()> {
let ctx = TestContext::new().await;
for text in ["DCACCOUNT:", "dcaccount:", "dcaccount://", "dcaccount:///"] {
let qr = check_qr(&ctx.ctx, text).await;
assert!(qr.is_err(), "Invalid {text:?} is parsed as dcaccount URL");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_tg_socks_proxy() -> Result<()> {
let t = TestContext::new().await;

View File

@@ -111,10 +111,10 @@ async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Resu
let qrcode_description = match chat.typ {
crate::constants::Chattype::Group => {
stock_str::secure_join_group_qr_description(context, &chat).await
stock_str::secure_join_group_qr_description(context, &chat)
}
crate::constants::Chattype::OutBroadcast => {
stock_str::secure_join_broadcast_qr_description(context, &chat).await
stock_str::secure_join_broadcast_qr_description(context, &chat)
}
_ => bail!("Unexpected chat type {}", chat.typ),
};
@@ -132,7 +132,7 @@ async fn generate_verification_qr(context: &Context) -> Result<String> {
let (avatar, displayname, addr, color) = self_info(context).await?;
inner_generate_secure_join_qr_code(
&stock_str::setup_contact_qr_description(context, &displayname, &addr).await,
&stock_str::setup_contact_qr_description(context, &displayname, &addr),
&securejoin::get_securejoin_qr(context, None).await?,
&color,
avatar,

View File

@@ -109,10 +109,9 @@ impl Context {
/// called.
pub(crate) async fn quota_needs_update(&self, transport_id: u32, ratelimit_secs: u64) -> bool {
let quota = self.quota.read().await;
quota
.get(&transport_id)
.filter(|quota| time_elapsed(&quota.modified) < Duration::from_secs(ratelimit_secs))
.is_none()
quota.get(&transport_id).is_none_or(|quota| {
time_elapsed(&quota.modified) >= Duration::from_secs(ratelimit_secs)
})
}
/// Updates `quota.recent`, sets `quota.modified` to the current time
@@ -124,11 +123,15 @@ impl Context {
/// 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) -> Result<()> {
let transport_id = session.transport_id();
info!(self, "Transport {transport_id}: Updating quota.");
let quota = if session.can_check_quota() {
let folders = get_watched_folders(self).await?;
get_unique_quota_roots_and_usage(session, folders).await
} else {
Err(anyhow!(stock_str::not_supported_by_provider(self).await))
Err(anyhow!(stock_str::not_supported_by_provider(self)))
};
if let Ok(quota) = &quota {
@@ -143,26 +146,29 @@ impl Context {
Some(&highest.to_string()),
)
.await?;
let mut msg =
Message::new_text(stock_str::quota_exceeding(self, highest).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, "cannot get highest quota usage: {:#}", err),
Err(err) => warn!(
self,
"Transport {transport_id}: Cannot get highest quota usage: {err:#}"
),
}
}
self.quota.write().await.insert(
session.transport_id(),
transport_id,
QuotaInfo {
recent: quota,
modified: tools::Time::now(),
},
);
info!(self, "Transport {transport_id}: Updated quota.");
self.emit_event(EventType::ConnectivityChanged);
Ok(())
}

View File

@@ -347,8 +347,7 @@ impl Chat {
if self
.param
.get_i64(Param::LastReactionTimestamp)
.filter(|&reaction_timestamp| reaction_timestamp > timestamp)
.is_none()
.is_none_or(|reaction_timestamp| reaction_timestamp <= timestamp)
{
return Ok(None);
};
@@ -393,7 +392,9 @@ mod tests {
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::contact::{Contact, Origin};
use crate::message::{MessageState, Viewtype, delete_msgs};
use crate::key::{load_self_public_key, load_self_secret_key};
use crate::message::{MessageState, Viewtype, delete_msgs, markseen_msgs};
use crate::pgp::{SeipdVersion, pk_encrypt};
use crate::receive_imf::receive_imf;
use crate::sql::housekeeping;
use crate::test_utils::E2EE_INFO_MSGS;
@@ -956,4 +957,152 @@ Content-Disposition: reaction\n\
}
Ok(())
}
/// Tests that if reaction requests a read receipt,
/// no read receipt is sent when the chat is marked as noticed.
///
/// Reactions create hidden messages in the chat,
/// and when marking the chat as noticed marks
/// such messages as seen, read receipts should never be sent
/// to avoid the sender of reaction from learning
/// that receiver opened the chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_reaction_request_mdn() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = alice.create_chat_id(bob).await;
let alice_sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
let bob_msg = bob.recv_msg(&alice_sent_msg).await;
bob_msg.chat_id.accept(bob).await?;
assert_eq!(bob_msg.state, MessageState::InFresh);
let bob_chat_id = bob_msg.chat_id;
bob_chat_id.accept(bob).await?;
markseen_msgs(bob, vec![bob_msg.id]).await?;
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
(ContactId::SELF,)
)
.await?,
1
);
bob.sql.execute("DELETE FROM smtp_mdns", ()).await?;
// Construct reaction with an MDN request.
// Note the `Chat-Disposition-Notification-To` header.
let known_id = bob_msg.rfc724_mid;
let new_id = "e2b6e69e-4124-4e2a-b79f-e4f1be667165@localhost";
let plain_text = format!(
"Content-Type: text/plain; charset=\"utf-8\"; protected-headers=\"v1\"; \r
hp=\"cipher\"\r
Content-Disposition: reaction\r
From: \"Alice\" <alice@example.org>\r
To: \"Bob\" <bob@example.net>\r
Subject: Message from Alice\r
Date: Sat, 14 Mar 2026 01:02:03 +0000\r
In-Reply-To: <{known_id}>\r
References: <{known_id}>\r
Chat-Version: 1.0\r
Chat-Disposition-Notification-To: alice@example.org\r
Message-ID: <{new_id}>\r
HP-Outer: From: <alice@example.org>\r
HP-Outer: To: \"hidden-recipients\": ;\r
HP-Outer: Subject: [...]\r
HP-Outer: Date: Sat, 14 Mar 2026 01:02:03 +0000\r
HP-Outer: Message-ID: <{new_id}>\r
HP-Outer: In-Reply-To: <{known_id}>\r
HP-Outer: References: <{known_id}>\r
HP-Outer: Chat-Version: 1.0\r
Content-Transfer-Encoding: base64\r
\r
8J+RgA==\r
"
);
let alice_public_key = load_self_public_key(alice).await?;
let bob_public_key = load_self_public_key(bob).await?;
let alice_secret_key = load_self_secret_key(alice).await?;
let public_keys_for_encryption = vec![alice_public_key, bob_public_key];
let compress = true;
let encrypted_payload = pk_encrypt(
plain_text.as_bytes().to_vec(),
public_keys_for_encryption,
alice_secret_key,
compress,
SeipdVersion::V2,
)
.await?;
let boundary = "boundary123";
let rcvd_mail = format!(
"From: <alice@example.org>\r
To: \"hidden-recipients\": ;\r
Subject: [...]\r
Date: Sat, 14 Mar 2026 01:02:03 +0000\r
Message-ID: <{new_id}>\r
In-Reply-To: <{known_id}>\r
References: <{known_id}>\r
Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\";\r
boundary=\"{boundary}\"\r
MIME-Version: 1.0\r
\r
--{boundary}\r
Content-Type: application/pgp-encrypted; charset=\"utf-8\"\r
Content-Description: PGP/MIME version identification\r
Content-Transfer-Encoding: 7bit\r
\r
Version: 1\r
\r
--{boundary}\r
Content-Type: application/octet-stream; name=\"encrypted.asc\";\r
charset=\"utf-8\"\r
Content-Description: OpenPGP encrypted message\r
Content-Disposition: inline; filename=\"encrypted.asc\";\r
Content-Transfer-Encoding: 7bit\r
\r
{encrypted_payload}
--{boundary}--\r
"
);
let received = receive_imf(bob, rcvd_mail.as_bytes(), false)
.await?
.unwrap();
let bob_hidden_msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap())
.await
.unwrap();
assert!(bob_hidden_msg.hidden);
assert_eq!(bob_hidden_msg.chat_id, bob_chat_id);
// Bob does not see new message and cannot mark it as seen directly,
// but can mark the chat as noticed when opening it.
marknoticed_chat(bob, bob_chat_id).await?;
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?",
(ContactId::SELF,)
)
.await?,
0,
"Bob should not send MDN to Alice"
);
// MDN request was ignored, but reaction was not.
let reactions = get_msg_reactions(bob, bob_msg.id).await?;
assert_eq!(reactions.reactions.len(), 1);
assert_eq!(
reactions.emoji_sorted_by_frequency(),
vec![("👀".to_string(), 1)]
);
Ok(())
}
}

View File

@@ -45,6 +45,7 @@ use crate::securejoin::{
self, get_secure_join_step, handle_securejoin_handshake, observe_securejoin_on_other_device,
};
use crate::simplify;
use crate::smtp::msg_has_pending_smtp_job;
use crate::stats::STATISTICS_BOT_EMAIL;
use crate::stock_str;
use crate::sync::Sync::*;
@@ -582,14 +583,7 @@ pub(crate) async fn receive_imf_inner(
(rfc724_mid_orig, &self_addr),
)
.await?;
if !context
.sql
.exists(
"SELECT COUNT(*) FROM smtp WHERE rfc724_mid=?",
(rfc724_mid_orig,),
)
.await?
{
if !msg_has_pending_smtp_job(context, msg_id).await? {
msg_id.set_delivered(context).await?;
}
return Ok(None);
@@ -727,10 +721,9 @@ pub(crate) async fn receive_imf_inner(
let show_emails = ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?)
.unwrap_or_default();
let allow_creation = if mime_parser.decrypting_failed {
let allow_creation = if mime_parser.decryption_error.is_some() {
false
} else if mime_parser.is_system_message != SystemMessage::AutocryptSetupMessage
&& is_dc_message == MessengerMessage::No
} else if is_dc_message == MessengerMessage::No
&& !context.get_config_bool(Config::IsChatmail).await?
{
// the message is a classic email in a classic profile
@@ -880,8 +873,7 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
let instance = if mime_parser
.parts
.first()
.filter(|part| part.typ == Viewtype::Webxdc)
.is_some()
.is_some_and(|part| part.typ == Viewtype::Webxdc)
{
can_info_msg = false;
Some(
@@ -1012,11 +1004,11 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
&& msg.chat_visibility == ChatVisibility::Archived;
updated_chats
.entry(msg.chat_id)
.and_modify(|ts| *ts = cmp::max(*ts, msg.timestamp_sort))
.or_insert(msg.timestamp_sort);
.and_modify(|pos| *pos = cmp::max(*pos, (msg.timestamp_sort, msg.id)))
.or_insert((msg.timestamp_sort, msg.id));
}
}
for (chat_id, timestamp_sort) in updated_chats {
for (chat_id, (timestamp_sort, msg_id)) in updated_chats {
context
.sql
.execute(
@@ -1025,12 +1017,13 @@ UPDATE msgs SET state=? WHERE
state=? AND
hidden=0 AND
chat_id=? AND
timestamp<?",
(timestamp,id)<(?,?)",
(
MessageState::InNoticed,
MessageState::InFresh,
chat_id,
timestamp_sort,
msg_id,
),
)
.await
@@ -1213,14 +1206,18 @@ async fn decide_chat_assignment(
{
info!(context, "Call state changed (TRASH).");
true
} else if mime_parser.decrypting_failed && !mime_parser.incoming {
} else if let Some(ref decryption_error) = mime_parser.decryption_error
&& !mime_parser.incoming
{
// Outgoing undecryptable message.
let last_time = context
.get_config_i64(Config::LastCantDecryptOutgoingMsgs)
.await?;
let now = tools::time();
let update_config = if last_time.saturating_add(24 * 60 * 60) <= now {
let txt = "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions.";
let txt = format!(
"⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions. (Error: {decryption_error}, {rfc724_mid})."
);
let mut msg = Message::new_text(txt.to_string());
chat::add_device_msg(context, None, Some(&mut msg))
.await
@@ -1322,15 +1319,35 @@ async fn decide_chat_assignment(
// no database row and ChatId yet.
let mut num_recipients = 0;
let mut has_self_addr = false;
for recipient in &mime_parser.recipients {
has_self_addr |= context.is_self_addr(&recipient.addr).await?;
if addr_cmp(&recipient.addr, &mime_parser.from.addr) {
continue;
if let Some((sender_fingerprint, intended_recipient_fingerprints)) = mime_parser
.signature
.as_ref()
.filter(|(_sender_fingerprint, fps)| !fps.is_empty())
{
// The message is signed and has intended recipient fingerprints.
// If the message has intended recipient fingerprint and is not trashed already,
// then it is intended for us.
has_self_addr = true;
num_recipients = intended_recipient_fingerprints
.iter()
.filter(|fp| *fp != sender_fingerprint)
.count();
} else {
// Message has no intended recipient fingerprints
// or is not signed, count the `To` field recipients.
for recipient in &mime_parser.recipients {
has_self_addr |= context.is_self_addr(&recipient.addr).await?;
if addr_cmp(&recipient.addr, &mime_parser.from.addr) {
continue;
}
num_recipients += 1;
}
if from_id != ContactId::SELF && !has_self_addr {
num_recipients += 1;
}
num_recipients += 1;
}
if from_id != ContactId::SELF && !has_self_addr {
num_recipients += 1;
}
let mut can_be_11_chat_log = String::new();
let mut l = |cond: bool, s: String| {
@@ -1817,6 +1834,16 @@ async fn add_parts(
}
}
// Sort message to the bottom if we are not in the chat
// so if we are added via QR code scan
// the message about our addition goes after all the info messages.
// Info messages are sorted by local smeared_timestamp()
// which advances quickly during SecureJoin,
// while "member added" message may have older timestamp
// corresponding to the sender clock.
// In practice inviter clock may even be slightly in the past.
let sort_to_bottom = !chat.is_self_in_chat(context).await?;
let is_location_kml = mime_parser.location_kml.is_some();
let mut group_changes = match chat.typ {
_ if chat.id.is_special() => GroupChangesInfo::default(),
@@ -1867,16 +1894,8 @@ async fn add_parts(
};
let in_fresh = state == MessageState::InFresh;
let sort_to_bottom = false;
let received = true;
let sort_timestamp = chat_id
.calc_sort_timestamp(
context,
mime_parser.timestamp_sent,
sort_to_bottom,
received,
mime_parser.incoming,
)
.calc_sort_timestamp(context, mime_parser.timestamp_sent, sort_to_bottom)
.await?;
// Apply ephemeral timer changes to the chat.
@@ -2272,7 +2291,7 @@ RETURNING id
if trash { 0 } else { ephemeral_timestamp },
if trash {
DownloadState::Done
} else if mime_parser.decrypting_failed {
} else if mime_parser.decryption_error.is_some() {
DownloadState::Undecipherable
} else if let PreMessageMode::Pre {..} = mime_parser.pre_message {
DownloadState::Available
@@ -2336,7 +2355,7 @@ RETURNING id
info!(
context,
"Message has {icnt} parts and is assigned to chat #{chat_id}."
"Message has {icnt} parts and is assigned to chat #{chat_id}, timestamp={sort_timestamp}."
);
if !chat_id.is_trash() && !hidden {
@@ -2685,7 +2704,7 @@ async fn lookup_or_create_adhoc_group(
allow_creation: bool,
create_blocked: Blocked,
) -> Result<Option<(ChatId, Blocked, bool)>> {
if mime_parser.decrypting_failed {
if mime_parser.decryption_error.is_some() {
warn!(
context,
"Not creating ad-hoc group for message that cannot be decrypted."
@@ -2907,7 +2926,7 @@ async fn create_group(
if let Some(chat_id) = chat_id {
Ok(Some((chat_id, chat_id_blocked)))
} else if mime_parser.decrypting_failed {
} else if mime_parser.decryption_error.is_some() {
// It is possible that the message was sent to a valid,
// yet unknown group, which was rejected because
// Chat-Group-Name, which is in the encrypted part, was
@@ -3338,7 +3357,7 @@ async fn apply_chat_name_avatar_and_description_changes(
let old_name = &sanitize_single_line(old_name);
better_msg.get_or_insert(
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
stock_str::msg_broadcast_name_changed(context, old_name, grpname).await
stock_str::msg_broadcast_name_changed(context, old_name, grpname)
} else {
stock_str::msg_grp_name(context, old_name, grpname, from_id).await
},
@@ -3401,7 +3420,7 @@ async fn apply_chat_name_avatar_and_description_changes(
// apart from that, the group-avatar is send along with various other messages
better_msg.get_or_insert(
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
stock_str::msg_broadcast_img_changed(context).await
stock_str::msg_broadcast_img_changed(context)
} else {
match avatar_action {
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
@@ -3841,7 +3860,7 @@ async fn apply_in_broadcast_changes(
info!(context, "No-op broadcast 'Member added' message (TRASH)");
"".to_string()
} else {
stock_str::msg_you_joined_broadcast(context).await
stock_str::msg_you_joined_broadcast(context)
};
better_msg.get_or_insert(msg);
@@ -3857,7 +3876,7 @@ async fn apply_in_broadcast_changes(
chat::delete_broadcast_secret(context, chat.id).await?;
if from_id == ContactId::SELF {
better_msg.get_or_insert(stock_str::msg_you_left_broadcast(context).await);
better_msg.get_or_insert(stock_str::msg_you_left_broadcast(context));
} else {
better_msg.get_or_insert(
stock_str::msg_del_member_local(context, ContactId::SELF, from_id).await,

View File

@@ -13,9 +13,10 @@ use crate::constants::DC_GCL_FOR_FORWARDING;
use crate::contact;
use crate::imap::prefetch_should_download;
use crate::imex::{ImexMode, imex};
use crate::key;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{
E2EE_INFO_MSGS, TestContext, TestContextManager, get_chat_msg, mark_as_verified,
E2EE_INFO_MSGS, TestContext, TestContextManager, alice_keypair, get_chat_msg, mark_as_verified,
};
use crate::tools::{SystemTime, time};
@@ -819,9 +820,12 @@ async fn load_imf_email(context: &Context, imf_raw: &[u8]) -> Message {
.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();
let msg_id = chats.get_msg_id(0).unwrap().unwrap();
let received_msg = receive_imf(context, imf_raw, false)
.await
.expect("receive_imf failure")
.expect("No message received");
assert_eq!(received_msg.msg_ids.len(), 1);
let msg_id = received_msg.msg_ids[0];
Message::load_from_db(context, msg_id).await.unwrap()
}
@@ -2872,9 +2876,8 @@ async fn test_rfc1847_encapsulation() -> Result<()> {
// Alice sends a message to Bob using Thunderbird.
let raw = include_bytes!("../../test-data/message/rfc1847_encapsulation.eml");
receive_imf(bob, raw, false).await?;
let msg = bob.get_last_msg().await;
let msg = load_imf_email(bob, raw).await;
assert!(msg.get_showpadlock());
Ok(())
@@ -3082,8 +3085,8 @@ async fn test_auto_accept_for_bots() -> Result<()> {
async fn test_auto_accept_group_for_bots() -> Result<()> {
let t = TestContext::new_alice().await;
t.set_config(Config::Bot, Some("1")).await.unwrap();
receive_imf(&t, GRP_MAIL, false).await?;
let msg = t.get_last_msg().await;
let msg = load_imf_email(&t, GRP_MAIL).await;
let chat = chat::Chat::load_from_db(&t, msg.chat_id).await?;
assert!(!chat.is_contact_request());
Ok(())
@@ -3327,7 +3330,7 @@ async fn test_outgoing_undecryptable() -> Result<()> {
assert!(
dev_msg
.text
.contains("⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions.")
.starts_with("⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions. (Error:")
);
let raw = include_bytes!("../../test-data/message/thunderbird_encrypted_signed.eml");
@@ -3556,9 +3559,9 @@ async fn test_messed_up_message_id() -> Result<()> {
let t = TestContext::new_bob().await;
let raw = include_bytes!("../../test-data/message/messed_up_message_id.eml");
receive_imf(&t, raw, false).await?;
let msg = load_imf_email(&t, raw).await;
assert_eq!(
t.get_last_msg().await.rfc724_mid,
msg.rfc724_mid,
"0bb9ffe1-2596-d997-95b4-1fef8cc4808e@example.org"
);
@@ -5108,97 +5111,75 @@ async fn test_dont_verify_by_verified_by_unknown() -> Result<()> {
Ok(())
}
/// Tests that second device assigns outgoing encrypted messages
/// to 1:1 chat with key-contact even if the key of the contact is unknown.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recv_outgoing_msg_before_securejoin() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let a0 = &tcm.elena().await;
let a1 = &tcm.elena().await;
tcm.execute_securejoin(bob, a0).await;
let chat_id_a0_bob = a0.create_chat_id(bob).await;
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
bob.recv_msg(&sent_msg).await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(chat_a1.typ, Chattype::Group);
assert!(!chat_a1.is_encrypted(a1).await?);
assert_eq!(
chat::get_chat_contacts(a1, chat_a1.id).await?,
[a1.add_or_lookup_address_contact_id(bob).await]
);
assert_eq!(
chat_a1.why_cant_send(a1).await?,
Some(CantSendReason::NotAMember)
);
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi again").await;
bob.recv_msg(&sent_msg).await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
assert_eq!(msg_a1.chat_id, chat_a1.id);
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(
chat_a1.why_cant_send(a1).await?,
Some(CantSendReason::NotAMember)
);
let msg_a1 = tcm.send_recv(bob, a1, "Hi back").await;
assert!(msg_a1.get_showpadlock());
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(chat_a1.typ, Chattype::Single);
assert!(chat_a1.is_encrypted(a1).await?);
// Weird, but fine, anyway the bigger problem is the conversation split into two chats.
assert_eq!(
chat_a1.why_cant_send(a1).await?,
Some(CantSendReason::ContactRequest)
);
let a0 = &tcm.alice().await;
let a1 = &tcm.alice().await;
tcm.execute_securejoin(bob, a0).await;
let chat_id_a0_bob = a0.create_chat_id(bob).await;
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
bob.recv_msg(&sent_msg).await;
// Device a1 does not have Bob's key.
// Message is still received in an encrypted 1:1 chat with Bob.
// a1 learns the fingerprint of Bob from the Intended Recipient Fingerprint packet,
// but not the key.
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(chat_a1.typ, Chattype::Single);
assert!(chat_a1.is_encrypted(a1).await?);
// Cannot send because a1 does not have Bob's key.
assert!(!chat_a1.can_send(a1).await?);
assert_eq!(
chat_a1.why_cant_send(a1).await?,
Some(CantSendReason::MissingKey)
);
assert_eq!(
chat::get_chat_contacts(a1, chat_a1.id).await?,
[a1.add_or_lookup_contact_id(bob).await]
[a1.add_or_lookup_contact_id_no_key(bob).await]
);
assert!(chat_a1.can_send(a1).await?);
assert!(!chat_a1.can_send(a1).await?);
let a1_chat_id = a1.create_chat_id(bob).await;
assert_eq!(a1_chat_id, msg_a1.chat_id);
Ok(())
}
/// Tests that outgoing message cannot be assigned to 1:1 chat
/// without the intended recipient fingerprint.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recv_outgoing_msg_before_having_key_and_after() -> Result<()> {
async fn test_recv_outgoing_msg_no_intended_recipient_fingerprint() -> Result<()> {
let mut tcm = TestContextManager::new();
let a0 = &tcm.elena().await;
let a1 = &tcm.elena().await;
let bob = &tcm.bob().await;
let alice = &tcm.alice().await;
tcm.execute_securejoin(bob, a0).await;
let chat_id_a0_bob = a0.create_chat_id(bob).await;
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(chat_a1.typ, Chattype::Group);
assert!(!chat_a1.is_encrypted(a1).await?);
let payload = include_bytes!(
"../../test-data/message/alice_to_bob_no_intended_recipient_fingerprint.eml"
);
// Alice does not have Bob's key.
// Message is encrypted, but is received in ad hoc group with Bob's address.
let rcvd_msg = receive_imf(alice, payload, false).await?.unwrap();
let msg_alice = Message::load_from_db(alice, rcvd_msg.msg_ids[0]).await?;
assert!(msg_alice.get_showpadlock());
let chat_alice = Chat::load_from_db(alice, msg_alice.chat_id).await?;
assert_eq!(chat_alice.typ, Chattype::Group);
assert!(!chat_alice.is_encrypted(alice).await?);
// Cannot send because Bob's key is unknown.
assert!(!chat_alice.can_send(alice).await?);
assert_eq!(
chat_alice.why_cant_send(alice).await?,
Some(CantSendReason::NotAMember)
);
// Device a1 somehow learns Bob's key and creates the corresponding chat. However, this doesn't
// help because we only look up key contacts by address in a particular chat and the new chat
// isn't referenced by the received message. This is fixed by sending and receiving Intended
// Recipient Fingerprint subpackets which elena doesn't send.
a1.create_chat_id(bob).await;
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi again").await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
assert_eq!(msg_a1.chat_id, chat_a1.id);
Ok(())
}
@@ -5581,3 +5562,97 @@ async fn test_calendar_alternative() -> Result<()> {
Ok(())
}
/// Tests that outgoing encrypted messages are detected
/// by verifying own signature, completely ignoring From address.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_outgoing_determined_by_signature() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// alice_dev2: same key, different address.
let different_from = "very@different.from";
assert!(!alice.is_self_addr(different_from).await?);
let alice_dev2 = &tcm.unconfigured().await;
alice_dev2.configure_addr(different_from).await;
key::store_self_keypair(alice_dev2, &alice_keypair()).await?;
assert_ne!(
alice.get_config(Config::Addr).await?.unwrap(),
different_from
);
// Send message from alice_dev2 and check alice sees it as outgoing
let chat_id = alice_dev2.create_chat_id(bob).await;
let sent_msg = alice_dev2.send_text(chat_id, "hello from new device").await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.state, MessageState::OutDelivered);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mark_message_as_delivered_only_after_sent_out_fully() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
alice.set_config_bool(Config::BccSelf, true).await?;
let alice_chat_id = alice.create_chat_id(bob).await;
let file_bytes = include_bytes!("../../test-data/image/screenshot.gif");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(alice, "a.jpg", file_bytes, None)?;
let msg_id = chat::send_msg(alice, alice_chat_id, &mut msg)
.await
.unwrap();
let (pre_msg_id, pre_msg_payload) = first_row_in_smtp_queue(alice).await;
assert_eq!(msg_id, pre_msg_id);
assert!(pre_msg_payload.len() < file_bytes.len());
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutPending);
// Alice receives her own pre-message because of bcc_self
// This should not yet mark the message as delivered,
// because not everything was sent,
// but it does remove the pre-message from the SMTP queue
receive_imf(alice, pre_msg_payload.as_bytes(), false).await?;
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutPending);
let (post_msg_id, post_msg_payload) = first_row_in_smtp_queue(alice).await;
assert_eq!(msg_id, post_msg_id);
assert!(post_msg_payload.len() > file_bytes.len());
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutPending);
// Alice receives her own post-message because of bcc_self
// This should now mark the message as delivered,
// because everything was sent by now.
receive_imf(alice, post_msg_payload.as_bytes(), false).await?;
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutDelivered);
Ok(())
}
/// Queries the first sent message in the SMTP queue
/// without removing it from the SMTP queue.
/// This simulates the case that a message is successfully sent out,
/// but the 'OK' answer from the server doesn't arrive,
/// so that the SMTP row stays in the database.
pub(crate) async fn first_row_in_smtp_queue(alice: &TestContext) -> (MsgId, String) {
alice
.sql
.query_row_optional(
r#"
SELECT id, msg_id, mime, recipients
FROM smtp
ORDER BY id"#,
(),
|row| {
let msg_id: MsgId = row.get(1)?;
let mime: String = row.get(2)?;
Ok((msg_id, mime))
},
)
.await
.expect("query_row_optional failed")
.expect("No SMTP row found")
}

View File

@@ -355,6 +355,7 @@ async fn inbox_loop(
stop_token,
} = inbox_handlers;
let transport_id = connection.transport_id();
let ctx1 = ctx.clone();
let fut = async move {
let ctx = ctx1;
@@ -368,23 +369,34 @@ async fn inbox_loop(
let session = if let Some(session) = old_session.take() {
session
} else {
info!(ctx, "Preparing new IMAP session for inbox.");
info!(
ctx,
"Transport {transport_id}: Preparing new IMAP session for inbox."
);
match connection.prepare(&ctx).await {
Err(err) => {
warn!(ctx, "Failed to prepare inbox connection: {err:#}.");
warn!(
ctx,
"Transport {transport_id}: Failed to prepare inbox connection: {err:#}."
);
continue;
}
Ok(session) => session,
Ok(session) => {
info!(
ctx,
"Transport {transport_id}: Prepared new IMAP session for inbox."
);
session
}
}
};
match inbox_fetch_idle(&ctx, &mut connection, session).await {
Err(err) => warn!(ctx, "Failed inbox fetch_idle: {err:#}."),
Err(err) => warn!(
ctx,
"Transport {transport_id}: Failed inbox fetch_idle: {err:#}."
),
Ok(session) => {
info!(
ctx,
"IMAP loop iteration for inbox finished, keeping the session."
);
old_session = Some(session);
}
}
@@ -394,7 +406,7 @@ async fn inbox_loop(
stop_token
.cancelled()
.map(|_| {
info!(ctx, "Shutting down inbox loop.");
info!(ctx, "Transport {transport_id}: Shutting down inbox loop.");
})
.race(fut)
.await;
@@ -431,17 +443,25 @@ pub async fn convert_folder_meaning(
}
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
let transport_id = session.transport_id();
// Update quota no more than once a minute.
if ctx.quota_needs_update(session.transport_id(), 60).await
&& let Err(err) = ctx.update_recent_quota(&mut session).await
{
warn!(ctx, "Failed to update quota: {:#}.", err);
warn!(
ctx,
"Transport {transport_id}: Failed to update quota: {err:#}."
);
}
if let Ok(()) = imap.resync_request_receiver.try_recv()
&& let Err(err) = session.resync_folders(ctx).await
{
warn!(ctx, "Failed to resync folders: {:#}.", err);
warn!(
ctx,
"Transport {transport_id}: Failed to resync folders: {err:#}."
);
imap.resync_request_sender.try_send(()).ok();
}
@@ -456,7 +476,10 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
}
}
Err(err) => {
warn!(ctx, "Failed to get last housekeeping time: {}", err);
warn!(
ctx,
"Transport {transport_id}: Failed to get last housekeeping time: {err:#}"
);
}
};
@@ -486,6 +509,8 @@ async fn fetch_idle(
mut session: Session,
folder_meaning: FolderMeaning,
) -> Result<Session> {
let transport_id = session.transport_id();
let Some((folder_config, watch_folder)) = convert_folder_meaning(ctx, folder_meaning).await?
else {
// The folder is not configured.
@@ -537,7 +562,7 @@ async fn fetch_idle(
if !session.can_idle() {
info!(
ctx,
"IMAP session does not support IDLE, going to fake idle."
"Transport {transport_id}: IMAP session does not support IDLE, going to fake idle."
);
connection.fake_idle(ctx, watch_folder).await?;
return Ok(session);
@@ -550,15 +575,14 @@ async fn fetch_idle(
.log_err(ctx)
.unwrap_or_default()
{
info!(ctx, "IMAP IDLE is disabled, going to fake idle.");
info!(
ctx,
"Transport {transport_id}: IMAP IDLE is disabled, going to fake idle."
);
connection.fake_idle(ctx, watch_folder).await?;
return Ok(session);
}
info!(
ctx,
"IMAP session in folder {watch_folder:?} supports IDLE, using it."
);
let session = session
.idle(
ctx,
@@ -619,10 +643,6 @@ async fn simple_imap_loop(
match fetch_idle(&ctx, &mut connection, session, folder_meaning).await {
Err(err) => warn!(ctx, "Failed fetch_idle: {err:#}"),
Ok(session) => {
info!(
ctx,
"IMAP loop iteration for {folder_meaning} finished, keeping the session"
);
old_session = Some(session);
}
}
@@ -876,7 +896,7 @@ impl Scheduler {
let timeout_duration = std::time::Duration::from_secs(30);
let tracker = TaskTracker::new();
for b in self.inboxes.into_iter().chain(self.oboxes.into_iter()) {
for b in self.inboxes.into_iter().chain(self.oboxes) {
let context = context.clone();
tracker.spawn(async move {
tokio::time::timeout(timeout_duration, b.handle)

View File

@@ -109,36 +109,36 @@ impl DetailedConnectivity {
}
}
async fn to_string_imap(&self, context: &Context) -> String {
fn to_string_imap(&self, context: &Context) -> String {
match self {
DetailedConnectivity::Error(e) => stock_str::error(context, e).await,
DetailedConnectivity::Error(e) => stock_str::error(context, e),
DetailedConnectivity::Uninitialized => "Not started".to_string(),
DetailedConnectivity::Connecting => stock_str::connecting(context).await,
DetailedConnectivity::Connecting => stock_str::connecting(context),
DetailedConnectivity::Preparing | DetailedConnectivity::Working => {
stock_str::updating(context).await
stock_str::updating(context)
}
DetailedConnectivity::InterruptingIdle | DetailedConnectivity::Idle => {
stock_str::connected(context).await
stock_str::connected(context)
}
DetailedConnectivity::NotConfigured => "Not configured".to_string(),
}
}
async fn to_string_smtp(&self, context: &Context) -> String {
fn to_string_smtp(&self, context: &Context) -> String {
match self {
DetailedConnectivity::Error(e) => stock_str::error(context, e).await,
DetailedConnectivity::Error(e) => stock_str::error(context, e),
DetailedConnectivity::Uninitialized => {
"You did not try to send a message recently.".to_string()
}
DetailedConnectivity::Connecting => stock_str::connecting(context).await,
DetailedConnectivity::Working => stock_str::sending(context).await,
DetailedConnectivity::Connecting => stock_str::connecting(context),
DetailedConnectivity::Working => stock_str::sending(context),
// We don't know any more than that the last message was sent successfully;
// since sending the last message, connectivity could have changed, which we don't notice
// until another message is sent
DetailedConnectivity::InterruptingIdle
| DetailedConnectivity::Preparing
| DetailedConnectivity::Idle => stock_str::last_msg_sent_successfully(context).await,
| DetailedConnectivity::Idle => stock_str::last_msg_sent_successfully(context),
DetailedConnectivity::NotConfigured => "Not configured".to_string(),
}
}
@@ -369,8 +369,8 @@ impl Context {
.get_config_bool(crate::config::Config::ProxyEnabled)
.await?
{
let proxy_enabled = stock_str::proxy_enabled(self).await;
let proxy_description = stock_str::proxy_description(self).await;
let proxy_enabled = stock_str::proxy_enabled(self);
let proxy_description = stock_str::proxy_description(self);
ret += &format!("<h3>{proxy_enabled}</h3><ul><li>{proxy_description}</li></ul>");
}
@@ -396,7 +396,7 @@ impl Context {
_ => {
ret += &format!(
"<h3>{}</h3>\n</body></html>\n",
stock_str::not_connected(self).await
stock_str::not_connected(self)
);
return Ok(ret);
}
@@ -412,7 +412,7 @@ impl Context {
// =============================================================================================
let watched_folders = get_watched_folder_configs(self).await?;
let incoming_messages = stock_str::incoming_messages(self).await;
let incoming_messages = stock_str::incoming_messages(self);
ret += &format!("<h3>{incoming_messages}</h3><ul>");
let transports = self
@@ -449,7 +449,7 @@ impl Context {
ret += &*escaper::encode_minimal(&foldername);
}
ret += ":</b> ";
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self).await);
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self));
ret += "<br />";
folder_added = true;
@@ -464,7 +464,7 @@ impl Context {
ret += &*detailed.to_icon();
ret += " ";
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self).await);
ret += &*escaper::encode_minimal(&detailed.to_string_imap(self));
ret += "<br />";
}
}
@@ -504,13 +504,12 @@ impl Context {
);
}
let messages = stock_str::messages(self).await;
let messages = stock_str::messages(self);
let part_of_total_used = stock_str::part_of_total_used(
self,
&resource.usage.to_string(),
&resource.limit.to_string(),
)
.await;
);
ret += &match &resource.name {
Atom(resource_name) => {
format!(
@@ -531,7 +530,7 @@ impl Context {
// - most times, this is the only item anyway
let usage = &format_size(resource.usage * 1024, BINARY);
let limit = &format_size(resource.limit * 1024, BINARY);
stock_str::part_of_total_used(self, usage, limit).await
stock_str::part_of_total_used(self, usage, limit)
}
};
@@ -565,12 +564,12 @@ impl Context {
// Your last message was sent successfully
// =============================================================================================
let outgoing_messages = stock_str::outgoing_messages(self).await;
let outgoing_messages = stock_str::outgoing_messages(self);
ret += &format!("<h3>{outgoing_messages}</h3><ul><li>");
let detailed = smtp.get_detailed();
ret += &*detailed.to_icon();
ret += " ";
ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self).await);
ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self));
ret += "</li></ul>";
// =============================================================================================

View File

@@ -450,10 +450,8 @@ pub(crate) async fn handle_securejoin_handshake(
) {
let mut self_found = false;
let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
for (addr, key) in &mime_message.gossiped_keys {
if key.public_key.dc_fingerprint() == self_fingerprint
&& context.is_self_addr(addr).await?
{
for key in mime_message.gossiped_keys.values() {
if key.public_key.dc_fingerprint() == self_fingerprint {
self_found = true;
break;
}

View File

@@ -158,7 +158,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
chat::add_info_msg_with_cmd(
context,
private_chat_id,
&stock_str::securejoin_wait(context).await,
&stock_str::securejoin_wait(context),
SystemMessage::SecurejoinWait,
None,
time(),

View File

@@ -109,10 +109,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
let mut i = 0..msg_cnt;
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob));
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob));
let contact_alice_id = bob.add_or_lookup_contact_no_key(&alice).await.id;
let sent = bob.pop_sent_msg().await;
@@ -250,7 +250,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
let chat = alice.get_chat(&bob).await;
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
assert!(msg.is_info());
let expected_text = messages_e2ee_info_msg(&alice).await;
let expected_text = messages_e2ee_info_msg(&alice);
assert_eq!(msg.get_text(), expected_text);
}
@@ -295,7 +295,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
// The `SecurejoinWait` info message has been removed, but the e2ee notice remains.
let msg = get_chat_msg(&bob, bob_chat.get_id(), 0, 1).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -625,7 +625,7 @@ async fn test_secure_join_group_ex(v3: bool, remove_invite: bool) -> Result<()>
// - You added member bob@example.net
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
assert!(msg.is_info());
let expected_text = messages_e2ee_info_msg(&alice).await;
let expected_text = messages_e2ee_info_msg(&alice);
assert_eq!(msg.get_text(), expected_text);
}

View File

@@ -34,12 +34,10 @@ pub(crate) fn remove_message_footer<'a>(
// some providers encode `-- ` to `=2D-` which results in only `--`;
// use that only when no other footer is found
// and if the line before is empty and the line after is not empty
"--" => {
if (ix == 0 || lines.get(ix.saturating_sub(1)).is_none_or_empty())
&& !lines.get(ix + 1).is_none_or_empty()
{
nearly_standard_footer = Some(ix);
}
"--" if (ix == 0 || lines.get(ix.saturating_sub(1)).is_none_or_empty())
&& !lines.get(ix + 1).is_none_or_empty() =>
{
nearly_standard_footer = Some(ix);
}
_ => (),
}

View File

@@ -465,11 +465,7 @@ pub(crate) async fn send_msg_to_smtp(
match status {
SendResult::Retry => Err(format_err!("Retry")),
SendResult::Success => {
if !context
.sql
.exists("SELECT COUNT(*) FROM smtp WHERE msg_id=?", (msg_id,))
.await?
{
if !msg_has_pending_smtp_job(context, msg_id).await? {
msg_id.set_delivered(context).await?;
}
Ok(())
@@ -478,6 +474,16 @@ pub(crate) async fn send_msg_to_smtp(
}
}
pub(crate) async fn msg_has_pending_smtp_job(
context: &Context,
msg_id: MsgId,
) -> Result<bool, Error> {
context
.sql
.exists("SELECT COUNT(*) FROM smtp WHERE msg_id=?", (msg_id,))
.await
}
/// Attempts to send queued MDNs.
async fn send_mdns(context: &Context, connection: &mut Smtp) -> Result<()> {
loop {
@@ -706,7 +712,7 @@ pub(crate) async fn add_self_recipients(
// them. Normally the user should have a non-chatmail primary transport to send unencrypted
// messages.
if encrypted {
for addr in context.get_secondary_self_addrs().await? {
for addr in context.get_published_secondary_self_addrs().await? {
recipients.push(addr);
}
}

View File

@@ -883,6 +883,24 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
.log_err(context)
.ok();
// Cleanup `imap` and `imap_sync` entries for deleted transports.
//
// Transports may be deleted directly or via sync messages,
// so it is easier to cleanup orphaned entries in a single place.
context
.sql
.execute(
"DELETE FROM imap WHERE transport_id NOT IN (SELECT transports.id FROM transports)",
(),
)
.await
.log_err(context)
.ok();
context.sql.execute(
"DELETE FROM imap_sync WHERE transport_id NOT IN (SELECT transports.id FROM transports)",
(),
).await.log_err(context).ok();
// Delete POI locations
// which don't have corresponding message.
delete_orphaned_poi_locations(context)
@@ -901,7 +919,7 @@ async fn maybe_add_mvbox_move_deprecation_message(context: &Context) -> Result<(
if !context.get_config_bool(Config::OnlyFetchMvbox).await?
&& context.get_config_bool(Config::MvboxMove).await?
{
let mut msg = Message::new_text(stock_str::mvbox_move_deprecation(context).await);
let mut msg = Message::new_text(stock_str::mvbox_move_deprecation(context));
add_device_msg(context, Some("mvbox_move_deprecation"), Some(&mut msg)).await?;
}
Ok(())

View File

@@ -16,7 +16,6 @@ use crate::constants::ShowEmails;
use crate::context::Context;
use crate::key::DcKey;
use crate::log::warn;
use crate::message::MsgId;
use crate::provider::get_provider_info;
use crate::sql::Sql;
use crate::tools::{Time, inc_and_check, time_elapsed};
@@ -734,12 +733,6 @@ impl Sql {
Ok(())
}
async fn set_db_version_in_cache(&self, version: i32) -> Result<()> {
let mut lock = self.config_cache.write().await;
lock.insert(VERSION_CFG.to_string(), Some(format!("{version}")));
Ok(())
}
async fn execute_migration(&self, query: &str, version: i32) -> Result<()> {
self.execute_migration_transaction(
|transaction| {
@@ -1612,51 +1605,11 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
.await?;
}
if dbversion < 108 {
let version = 108;
let chunk_size = context.get_max_smtp_rcpt_to().await?;
sql.transaction(move |trans| {
Sql::set_db_version_trans(trans, version)?;
let id_max =
trans.query_row("SELECT IFNULL((SELECT MAX(id) FROM smtp), 0)", (), |row| {
let id_max: i64 = row.get(0)?;
Ok(id_max)
})?;
while let Some((id, rfc724_mid, mime, msg_id, recipients, retries)) = trans
.query_row(
"SELECT id, rfc724_mid, mime, msg_id, recipients, retries FROM smtp \
WHERE id<=? LIMIT 1",
(id_max,),
|row| {
let id: i64 = row.get(0)?;
let rfc724_mid: String = row.get(1)?;
let mime: String = row.get(2)?;
let msg_id: MsgId = row.get(3)?;
let recipients: String = row.get(4)?;
let retries: i64 = row.get(5)?;
Ok((id, rfc724_mid, mime, msg_id, recipients, retries))
},
)
.optional()?
{
trans.execute("DELETE FROM smtp WHERE id=?", (id,))?;
let recipients = recipients.split(' ').collect::<Vec<_>>();
for recipients in recipients.chunks(chunk_size) {
let recipients = recipients.join(" ");
trans.execute(
"INSERT INTO smtp (rfc724_mid, mime, msg_id, recipients, retries) \
VALUES (?, ?, ?, ?, ?)",
(&rfc724_mid, &mime, msg_id, recipients, retries),
)?;
}
}
Ok(())
})
.await
.with_context(|| format!("migration failed for version {version}"))?;
sql.set_db_version_in_cache(version).await?;
}
// Migration 108 is removed, it was using high level code
// to split SMTP queue messages into chunks with smaller number of recipients
// and started to fail later as high level code started
// expecting `transports` table that is only added in future migrations.
// Migration 108 was not changing the database schema.
if dbversion < 109 {
sql.execute_migration(
@@ -2343,6 +2296,26 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT;
.await?;
}
// Add an `is_published` flag to transports.
// Unpublished transports are not advertised to contacts,
// 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.
// The default is true, but when when the user updates the app,
// existing secondary transports are set to unpublished,
// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
inc_and_check(&mut migration_version, 149)?;
if dbversion < migration_version {
sql.execute_migration(
"ALTER TABLE transports ADD COLUMN is_published INTEGER DEFAULT 1 NOT NULL;
UPDATE transports SET is_published=0 WHERE addr!=(
SELECT value FROM config WHERE keyname='configured_addr'
)",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?

View File

@@ -71,24 +71,6 @@ struct InnerPool {
/// This mutex is locked when write connection
/// is outside the pool.
pub(crate) write_mutex: Arc<Mutex<()>>,
/// WAL checkpointing mutex.
///
/// This mutex ensures that no more than one thread
/// runs WAL checkpointing at the same time.
///
/// Normal procedures acquire either one read connection
/// or one write connection with a write mutex,
/// and return the resources without trying to acquire
/// more connections or trying to acquire write mutex
/// without returning the read connection first.
/// WAL checkpointing is special, it tries to acquire all
/// connections and the write mutex,
/// so two threads doing this at the same time
/// may result in a deadlock with one thread
/// waiting for a write lock and the other thread
/// waiting for a connection.
wal_checkpoint_mutex: Mutex<()>,
}
impl InnerPool {
@@ -209,7 +191,6 @@ impl Pool {
connections: parking_lot::Mutex::new(connections),
semaphore,
write_mutex: Default::default(),
wal_checkpoint_mutex: Default::default(),
});
Pool { inner }
}

View File

@@ -34,7 +34,6 @@ pub(crate) struct WalCheckpointStats {
/// 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> {
let _guard = pool.inner.wal_checkpoint_mutex.lock().await;
let t_start = Time::now();
// Do as much work as possible without blocking anybody.
@@ -48,22 +47,25 @@ pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
})?;
// Kick out writers.
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
// 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.
drop(conn);
let _write_lock = Arc::clone(&pool.inner.write_mutex).lock_owned().await;
let t_writers_blocked = Time::now();
let conn = pool.get(query_only).await?;
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
// readers.
let mut read_conns = Vec::with_capacity(crate::sql::Sql::N_DB_CONNECTIONS - 1);
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
let mut read_conns = Vec::with_capacity(Sql::N_DB_CONNECTIONS - 1);
for _ in 0..(Sql::N_DB_CONNECTIONS - 1) {
read_conns.push(pool.get(query_only).await?);
}
read_conns.clear();
// Checkpoint the remaining WAL pages without blocking readers.
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
let pages_total: i64 = row.get(1)?;
let pages_checkpointed: i64 = row.get(2)?;
@@ -71,7 +73,7 @@ pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
})
})?;
// Kick out readers to avoid blocking/SQLITE_BUSY.
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
for _ in 0..(Sql::N_DB_CONNECTIONS - 1) {
read_conns.push(pool.get(query_only).await?);
}
let t_readers_blocked = Time::now();

View File

@@ -281,7 +281,7 @@ async fn send_stats(context: &Context) -> Result<ChatId> {
let chat_id = get_stats_chat_id(context).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_text(crate::stock_str::stats_msg_body(context).await);
msg.set_text(crate::stock_str::stats_msg_body(context));
let stats = get_stats(context).await?;

View File

@@ -4,9 +4,9 @@ use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{Result, bail};
use parking_lot::RwLock;
use strum::EnumProperty as EnumPropertyTrait;
use strum_macros::EnumProperty;
use tokio::sync::RwLock;
use crate::accounts::Accounts;
use crate::blob::BlobObject;
@@ -463,17 +463,16 @@ impl StockStrings {
}
}
async fn translated(&self, id: StockMessage) -> String {
fn translated(&self, id: StockMessage) -> String {
self.translated_stockstrings
.read()
.await
.get(&(id as usize))
.map(AsRef::as_ref)
.unwrap_or_else(|| id.fallback())
.to_string()
}
async fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
if stockstring.contains("%1") && !id.fallback().contains("%1") {
bail!(
"translation {} contains invalid %1 placeholder, default is {}",
@@ -490,14 +489,13 @@ impl StockStrings {
}
self.translated_stockstrings
.write()
.await
.insert(id as usize, stockstring);
Ok(())
}
}
async fn translated(context: &Context, id: StockMessage) -> String {
context.translated_stockstrings.translated(id).await
fn translated(context: &Context, id: StockMessage) -> String {
context.translated_stockstrings.translated(id)
}
/// Helper trait only meant to be implemented for [`String`].
@@ -546,43 +544,43 @@ impl ContactId {
impl StockStringMods for String {}
/// Stock string: `No messages.`.
pub(crate) async fn no_messages(context: &Context) -> String {
translated(context, StockMessage::NoMessages).await
pub(crate) fn no_messages(context: &Context) -> String {
translated(context, StockMessage::NoMessages)
}
/// Stock string: `Me`.
pub(crate) async fn self_msg(context: &Context) -> String {
translated(context, StockMessage::SelfMsg).await
pub(crate) fn self_msg(context: &Context) -> String {
translated(context, StockMessage::SelfMsg)
}
/// Stock string: `Draft`.
pub(crate) async fn draft(context: &Context) -> String {
translated(context, StockMessage::Draft).await
pub(crate) fn draft(context: &Context) -> String {
translated(context, StockMessage::Draft)
}
/// Stock string: `Voice message`.
pub(crate) async fn voice_message(context: &Context) -> String {
translated(context, StockMessage::VoiceMessage).await
pub(crate) fn voice_message(context: &Context) -> String {
translated(context, StockMessage::VoiceMessage)
}
/// Stock string: `Image`.
pub(crate) async fn image(context: &Context) -> String {
translated(context, StockMessage::Image).await
pub(crate) fn image(context: &Context) -> String {
translated(context, StockMessage::Image)
}
/// Stock string: `Video`.
pub(crate) async fn video(context: &Context) -> String {
translated(context, StockMessage::Video).await
pub(crate) fn video(context: &Context) -> String {
translated(context, StockMessage::Video)
}
/// Stock string: `Audio`.
pub(crate) async fn audio(context: &Context) -> String {
translated(context, StockMessage::Audio).await
pub(crate) fn audio(context: &Context) -> String {
translated(context, StockMessage::Audio)
}
/// Stock string: `File`.
pub(crate) async fn file(context: &Context) -> String {
translated(context, StockMessage::File).await
pub(crate) fn file(context: &Context) -> String {
translated(context, StockMessage::File)
}
/// Stock string: `Group name changed from "%1$s" to "%2$s".`.
@@ -594,12 +592,10 @@ pub(crate) async fn msg_grp_name(
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouChangedGrpName)
.await
.replace1(from_group)
.replace2(to_group)
} else {
translated(context, StockMessage::MsgGrpNameChangedBy)
.await
.replace1(from_group)
.replace2(to_group)
.replace3(&by_contact.get_stock_name(context).await)
@@ -608,10 +604,9 @@ pub(crate) async fn msg_grp_name(
pub(crate) async fn msg_grp_img_changed(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouChangedGrpImg).await
translated(context, StockMessage::MsgYouChangedGrpImg)
} else {
translated(context, StockMessage::MsgGrpImgChangedBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
@@ -621,10 +616,9 @@ pub(crate) async fn msg_chat_description_changed(
by_contact: ContactId,
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouChangedDescription).await
translated(context, StockMessage::MsgYouChangedDescription)
} else {
translated(context, StockMessage::MsgChatDescriptionChangedBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
@@ -640,16 +634,11 @@ pub(crate) async fn msg_add_member_local(
) -> String {
let whom = added_member.get_stock_name(context).await;
if by_contact == ContactId::UNDEFINED {
translated(context, StockMessage::MsgAddMember)
.await
.replace1(&whom)
translated(context, StockMessage::MsgAddMember).replace1(&whom)
} else if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouAddMember)
.await
.replace1(&whom)
translated(context, StockMessage::MsgYouAddMember).replace1(&whom)
} else {
translated(context, StockMessage::MsgAddMemberBy)
.await
.replace1(&whom)
.replace2(&by_contact.get_stock_name(context).await)
}
@@ -666,16 +655,11 @@ pub(crate) async fn msg_del_member_local(
) -> String {
let whom = removed_member.get_stock_name(context).await;
if by_contact == ContactId::UNDEFINED {
translated(context, StockMessage::MsgDelMember)
.await
.replace1(&whom)
translated(context, StockMessage::MsgDelMember).replace1(&whom)
} else if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouDelMember)
.await
.replace1(&whom)
translated(context, StockMessage::MsgYouDelMember).replace1(&whom)
} else {
translated(context, StockMessage::MsgDelMemberBy)
.await
.replace1(&whom)
.replace2(&by_contact.get_stock_name(context).await)
}
@@ -684,22 +668,21 @@ pub(crate) async fn msg_del_member_local(
/// Stock string: `You left the group.` or `Group left by %1$s.`.
pub(crate) async fn msg_group_left_local(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouLeftGroup).await
translated(context, StockMessage::MsgYouLeftGroup)
} else {
translated(context, StockMessage::MsgGroupLeftBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
/// Stock string: `You left the channel.`
pub(crate) async fn msg_you_left_broadcast(context: &Context) -> String {
translated(context, StockMessage::MsgYouLeftBroadcast).await
pub(crate) fn msg_you_left_broadcast(context: &Context) -> String {
translated(context, StockMessage::MsgYouLeftBroadcast)
}
/// Stock string: `You joined the channel.`
pub(crate) async fn msg_you_joined_broadcast(context: &Context) -> String {
translated(context, StockMessage::MsgYouJoinedBroadcast).await
pub(crate) fn msg_you_joined_broadcast(context: &Context) -> String {
translated(context, StockMessage::MsgYouJoinedBroadcast)
}
/// Stock string: `%1$s invited you to join this channel. Waiting for the device of %2$s to reply…`.
@@ -709,7 +692,6 @@ pub(crate) async fn secure_join_broadcast_started(
) -> String {
if let Ok(contact) = Contact::get_by_id(context, inviter_contact_id).await {
translated(context, StockMessage::SecureJoinBroadcastStarted)
.await
.replace1(contact.get_display_name())
.replace2(contact.get_display_name())
} else {
@@ -718,16 +700,15 @@ pub(crate) async fn secure_join_broadcast_started(
}
/// Stock string: `Channel name changed from "1%s" to "2$s".`
pub(crate) async fn msg_broadcast_name_changed(context: &Context, from: &str, to: &str) -> String {
pub(crate) fn msg_broadcast_name_changed(context: &Context, from: &str, to: &str) -> String {
translated(context, StockMessage::MsgBroadcastNameChanged)
.await
.replace1(from)
.replace2(to)
}
/// Stock string `Channel image changed.`
pub(crate) async fn msg_broadcast_img_changed(context: &Context) -> String {
translated(context, StockMessage::MsgBroadcastImgChanged).await
pub(crate) fn msg_broadcast_img_changed(context: &Context) -> String {
translated(context, StockMessage::MsgBroadcastImgChanged)
}
/// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`.
@@ -739,12 +720,10 @@ pub(crate) async fn msg_reacted(
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouReacted)
.await
.replace1(reaction)
.replace2(summary)
} else {
translated(context, StockMessage::MsgReactedBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
.replace2(reaction)
.replace3(summary)
@@ -752,27 +731,26 @@ pub(crate) async fn msg_reacted(
}
/// Stock string: `GIF`.
pub(crate) async fn gif(context: &Context) -> String {
translated(context, StockMessage::Gif).await
pub(crate) fn gif(context: &Context) -> String {
translated(context, StockMessage::Gif)
}
/// Stock string: `No encryption.`.
pub(crate) async fn encr_none(context: &Context) -> String {
translated(context, StockMessage::EncrNone).await
pub(crate) fn encr_none(context: &Context) -> String {
translated(context, StockMessage::EncrNone)
}
/// Stock string: `Fingerprints`.
pub(crate) async fn finger_prints(context: &Context) -> String {
translated(context, StockMessage::FingerPrints).await
pub(crate) fn finger_prints(context: &Context) -> String {
translated(context, StockMessage::FingerPrints)
}
/// Stock string: `Group image deleted.`.
pub(crate) async fn msg_grp_img_deleted(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouDeletedGrpImg).await
translated(context, StockMessage::MsgYouDeletedGrpImg)
} else {
translated(context, StockMessage::MsgGrpImgDeletedBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
@@ -784,7 +762,6 @@ pub(crate) async fn secure_join_started(
) -> String {
if let Ok(contact) = Contact::get_by_id(context, inviter_contact_id).await {
translated(context, StockMessage::SecureJoinStarted)
.await
.replace1(contact.get_display_name())
.replace2(contact.get_display_name())
} else {
@@ -795,22 +772,21 @@ pub(crate) async fn secure_join_started(
/// Stock string: `%1$s replied, waiting for being added to the group…`.
pub(crate) async fn secure_join_replies(context: &Context, contact_id: ContactId) -> String {
translated(context, StockMessage::SecureJoinReplies)
.await
.replace1(&contact_id.get_stock_name(context).await)
}
/// Stock string: `Establishing connection, please wait…`.
pub(crate) async fn securejoin_wait(context: &Context) -> String {
translated(context, StockMessage::SecurejoinWait).await
pub(crate) fn securejoin_wait(context: &Context) -> String {
translated(context, StockMessage::SecurejoinWait)
}
/// Stock string: `❤️ Seems you're enjoying Delta Chat!`…
pub(crate) async fn donation_request(context: &Context) -> String {
translated(context, StockMessage::DonationRequest).await
pub(crate) fn donation_request(context: &Context) -> String {
translated(context, StockMessage::DonationRequest)
}
/// Stock string: `Outgoing video call` or `Outgoing audio call`.
pub(crate) async fn outgoing_call(context: &Context, has_video: bool) -> String {
pub(crate) fn outgoing_call(context: &Context, has_video: bool) -> String {
translated(
context,
if has_video {
@@ -819,11 +795,10 @@ pub(crate) async fn outgoing_call(context: &Context, has_video: bool) -> String
StockMessage::OutgoingAudioCall
},
)
.await
}
/// Stock string: `Incoming video call` or `Incoming audio call`.
pub(crate) async fn incoming_call(context: &Context, has_video: bool) -> String {
pub(crate) fn incoming_call(context: &Context, has_video: bool) -> String {
translated(
context,
if has_video {
@@ -832,26 +807,25 @@ pub(crate) async fn incoming_call(context: &Context, has_video: bool) -> String
StockMessage::IncomingAudioCall
},
)
.await
}
/// Stock string: `Declined call`.
pub(crate) async fn declined_call(context: &Context) -> String {
translated(context, StockMessage::DeclinedCall).await
pub(crate) fn declined_call(context: &Context) -> String {
translated(context, StockMessage::DeclinedCall)
}
/// Stock string: `Canceled call`.
pub(crate) async fn canceled_call(context: &Context) -> String {
translated(context, StockMessage::CanceledCall).await
pub(crate) fn canceled_call(context: &Context) -> String {
translated(context, StockMessage::CanceledCall)
}
/// Stock string: `Missed call`.
pub(crate) async fn missed_call(context: &Context) -> String {
translated(context, StockMessage::MissedCall).await
pub(crate) fn missed_call(context: &Context) -> String {
translated(context, StockMessage::MissedCall)
}
/// Stock string: `Scan to chat with %1$s`.
pub(crate) async fn setup_contact_qr_description(
pub(crate) fn setup_contact_qr_description(
context: &Context,
display_name: &str,
addr: &str,
@@ -861,113 +835,100 @@ pub(crate) async fn setup_contact_qr_description(
} else {
display_name.to_owned()
};
translated(context, StockMessage::SetupContactQRDescription)
.await
.replace1(&name)
translated(context, StockMessage::SetupContactQRDescription).replace1(&name)
}
/// Stock string: `Scan to join group %1$s`.
pub(crate) async fn secure_join_group_qr_description(context: &Context, chat: &Chat) -> String {
translated(context, StockMessage::SecureJoinGroupQRDescription)
.await
.replace1(chat.get_name())
pub(crate) fn secure_join_group_qr_description(context: &Context, chat: &Chat) -> String {
translated(context, StockMessage::SecureJoinGroupQRDescription).replace1(chat.get_name())
}
/// Stock string: `Scan to join channel %1$s`.
pub(crate) async fn secure_join_broadcast_qr_description(context: &Context, chat: &Chat) -> String {
translated(context, StockMessage::SecureJoinBrodcastQRDescription)
.await
.replace1(chat.get_name())
pub(crate) fn secure_join_broadcast_qr_description(context: &Context, chat: &Chat) -> String {
translated(context, StockMessage::SecureJoinBrodcastQRDescription).replace1(chat.get_name())
}
/// Stock string: `%1$s verified.`.
#[allow(dead_code)]
pub(crate) async fn contact_verified(context: &Context, contact: &Contact) -> String {
pub(crate) fn contact_verified(context: &Context, contact: &Contact) -> String {
let addr = contact.get_display_name();
translated(context, StockMessage::ContactVerified)
.await
.replace1(addr)
translated(context, StockMessage::ContactVerified).replace1(addr)
}
/// Stock string: `Archived chats`.
pub(crate) async fn archived_chats(context: &Context) -> String {
translated(context, StockMessage::ArchivedChats).await
pub(crate) fn archived_chats(context: &Context) -> String {
translated(context, StockMessage::ArchivedChats)
}
/// Stock string: `Multi Device Synchronization`.
pub(crate) async fn sync_msg_subject(context: &Context) -> String {
translated(context, StockMessage::SyncMsgSubject).await
pub(crate) fn sync_msg_subject(context: &Context) -> String {
translated(context, StockMessage::SyncMsgSubject)
}
/// Stock string: `This message is used to synchronize data between your devices.`.
pub(crate) async fn sync_msg_body(context: &Context) -> String {
translated(context, StockMessage::SyncMsgBody).await
pub(crate) fn sync_msg_body(context: &Context) -> String {
translated(context, StockMessage::SyncMsgBody)
}
/// Stock string: `Cannot login as \"%1$s\". Please check...`.
pub(crate) async fn cannot_login(context: &Context, user: &str) -> String {
translated(context, StockMessage::CannotLogin)
.await
.replace1(user)
pub(crate) fn cannot_login(context: &Context, user: &str) -> String {
translated(context, StockMessage::CannotLogin).replace1(user)
}
/// Stock string: `Location streaming enabled.`.
pub(crate) async fn msg_location_enabled(context: &Context) -> String {
translated(context, StockMessage::MsgLocationEnabled).await
pub(crate) fn msg_location_enabled(context: &Context) -> String {
translated(context, StockMessage::MsgLocationEnabled)
}
/// Stock string: `Location streaming enabled by ...`.
pub(crate) async fn msg_location_enabled_by(context: &Context, contact: ContactId) -> String {
if contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEnabledLocation).await
translated(context, StockMessage::MsgYouEnabledLocation)
} else {
translated(context, StockMessage::MsgLocationEnabledBy)
.await
.replace1(&contact.get_stock_name(context).await)
}
}
/// Stock string: `Location streaming disabled.`.
pub(crate) async fn msg_location_disabled(context: &Context) -> String {
translated(context, StockMessage::MsgLocationDisabled).await
pub(crate) fn msg_location_disabled(context: &Context) -> String {
translated(context, StockMessage::MsgLocationDisabled)
}
/// Stock string: `Location`.
pub(crate) async fn location(context: &Context) -> String {
translated(context, StockMessage::Location).await
pub(crate) fn location(context: &Context) -> String {
translated(context, StockMessage::Location)
}
/// Stock string: `Sticker`.
pub(crate) async fn sticker(context: &Context) -> String {
translated(context, StockMessage::Sticker).await
pub(crate) fn sticker(context: &Context) -> String {
translated(context, StockMessage::Sticker)
}
/// Stock string: `Device messages`.
pub(crate) async fn device_messages(context: &Context) -> String {
translated(context, StockMessage::DeviceMessages).await
pub(crate) fn device_messages(context: &Context) -> String {
translated(context, StockMessage::DeviceMessages)
}
/// Stock string: `Saved messages`.
pub(crate) async fn saved_messages(context: &Context) -> String {
translated(context, StockMessage::SavedMessages).await
pub(crate) fn saved_messages(context: &Context) -> String {
translated(context, StockMessage::SavedMessages)
}
/// Stock string: `Messages in this chat are generated locally by...`.
pub(crate) async fn device_messages_hint(context: &Context) -> String {
translated(context, StockMessage::DeviceMessagesHint).await
pub(crate) fn device_messages_hint(context: &Context) -> String {
translated(context, StockMessage::DeviceMessagesHint)
}
/// Stock string: `Welcome to Delta Chat! ...`.
pub(crate) async fn welcome_message(context: &Context) -> String {
translated(context, StockMessage::WelcomeMessage).await
pub(crate) fn welcome_message(context: &Context) -> String {
translated(context, StockMessage::WelcomeMessage)
}
/// Stock string: `Message from %1$s`.
// TODO: This can compute `self_name` itself instead of asking the caller to do this.
pub(crate) async fn subject_for_new_contact(context: &Context, self_name: &str) -> String {
translated(context, StockMessage::SubjectForNewContact)
.await
.replace1(self_name)
pub(crate) fn subject_for_new_contact(context: &Context, self_name: &str) -> String {
translated(context, StockMessage::SubjectForNewContact).replace1(self_name)
}
/// Stock string: `Message deletion timer is disabled.`.
@@ -976,10 +937,9 @@ pub(crate) async fn msg_ephemeral_timer_disabled(
by_contact: ContactId,
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouDisabledEphemeralTimer).await
translated(context, StockMessage::MsgYouDisabledEphemeralTimer)
} else {
translated(context, StockMessage::MsgEphemeralTimerDisabledBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
@@ -991,12 +951,9 @@ pub(crate) async fn msg_ephemeral_timer_enabled(
by_contact: ContactId,
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEnabledEphemeralTimer)
.await
.replace1(timer)
translated(context, StockMessage::MsgYouEnabledEphemeralTimer).replace1(timer)
} else {
translated(context, StockMessage::MsgEphemeralTimerEnabledBy)
.await
.replace1(timer)
.replace2(&by_contact.get_stock_name(context).await)
}
@@ -1005,10 +962,9 @@ pub(crate) async fn msg_ephemeral_timer_enabled(
/// Stock string: `Message deletion timer is set to 1 hour.`.
pub(crate) async fn msg_ephemeral_timer_hour(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerHour).await
translated(context, StockMessage::MsgYouEphemeralTimerHour)
} else {
translated(context, StockMessage::MsgEphemeralTimerHourBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
@@ -1016,10 +972,9 @@ pub(crate) async fn msg_ephemeral_timer_hour(context: &Context, by_contact: Cont
/// Stock string: `Message deletion timer is set to 1 day.`.
pub(crate) async fn msg_ephemeral_timer_day(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerDay).await
translated(context, StockMessage::MsgYouEphemeralTimerDay)
} else {
translated(context, StockMessage::MsgEphemeralTimerDayBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
@@ -1027,10 +982,9 @@ pub(crate) async fn msg_ephemeral_timer_day(context: &Context, by_contact: Conta
/// Stock string: `Message deletion timer is set to 1 week.`.
pub(crate) async fn msg_ephemeral_timer_week(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerWeek).await
translated(context, StockMessage::MsgYouEphemeralTimerWeek)
} else {
translated(context, StockMessage::MsgEphemeralTimerWeekBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
@@ -1038,57 +992,52 @@ pub(crate) async fn msg_ephemeral_timer_week(context: &Context, by_contact: Cont
/// Stock string: `Message deletion timer is set to 1 year.`.
pub(crate) async fn msg_ephemeral_timer_year(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerYear).await
translated(context, StockMessage::MsgYouEphemeralTimerYear)
} else {
translated(context, StockMessage::MsgEphemeralTimerYearBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
}
}
/// Stock string: `Error:\n\n“%1$s”`.
pub(crate) async fn configuration_failed(context: &Context, details: &str) -> String {
translated(context, StockMessage::ConfigurationFailed)
.await
.replace1(details)
pub(crate) fn configuration_failed(context: &Context, details: &str) -> String {
translated(context, StockMessage::ConfigurationFailed).replace1(details)
}
/// Stock string: `⚠️ Date or time of your device seem to be inaccurate (%1$s)...`.
// TODO: This could compute now itself.
pub(crate) async fn bad_time_msg_body(context: &Context, now: &str) -> String {
translated(context, StockMessage::BadTimeMsgBody)
.await
.replace1(now)
pub(crate) fn bad_time_msg_body(context: &Context, now: &str) -> String {
translated(context, StockMessage::BadTimeMsgBody).replace1(now)
}
/// Stock string: `⚠️ Your Delta Chat version might be outdated...`.
pub(crate) async fn update_reminder_msg_body(context: &Context) -> String {
translated(context, StockMessage::UpdateReminderMsgBody).await
pub(crate) fn update_reminder_msg_body(context: &Context) -> String {
translated(context, StockMessage::UpdateReminderMsgBody)
}
/// Stock string: `Could not find your mail server...`.
pub(crate) async fn error_no_network(context: &Context) -> String {
translated(context, StockMessage::ErrorNoNetwork).await
pub(crate) fn error_no_network(context: &Context) -> String {
translated(context, StockMessage::ErrorNoNetwork)
}
/// Stock string: `Messages are end-to-end encrypted.`, used in info-messages, UI may add smth. as `Tap to learn more.`
pub(crate) async fn messages_e2ee_info_msg(context: &Context) -> String {
translated(context, StockMessage::ChatProtectionEnabled).await
pub(crate) fn messages_e2ee_info_msg(context: &Context) -> String {
translated(context, StockMessage::ChatProtectionEnabled)
}
/// Stock string: `Messages are end-to-end encrypted.`
pub(crate) async fn messages_are_e2ee(context: &Context) -> String {
translated(context, StockMessage::MessagesAreE2ee).await
pub(crate) fn messages_are_e2ee(context: &Context) -> String {
translated(context, StockMessage::MessagesAreE2ee)
}
/// Stock string: `Reply`.
pub(crate) async fn reply_noun(context: &Context) -> String {
translated(context, StockMessage::ReplyNoun).await
pub(crate) fn reply_noun(context: &Context) -> String {
translated(context, StockMessage::ReplyNoun)
}
/// Stock string: `You deleted the \"Saved messages\" chat...`.
pub(crate) async fn self_deleted_msg_body(context: &Context) -> String {
translated(context, StockMessage::SelfDeletedMsgBody).await
pub(crate) fn self_deleted_msg_body(context: &Context) -> String {
translated(context, StockMessage::SelfDeletedMsgBody)
}
/// Stock string: `Message deletion timer is set to %1$s minutes.`.
@@ -1098,12 +1047,9 @@ pub(crate) async fn msg_ephemeral_timer_minutes(
by_contact: ContactId,
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerMinutes)
.await
.replace1(minutes)
translated(context, StockMessage::MsgYouEphemeralTimerMinutes).replace1(minutes)
} else {
translated(context, StockMessage::MsgEphemeralTimerMinutesBy)
.await
.replace1(minutes)
.replace2(&by_contact.get_stock_name(context).await)
}
@@ -1116,12 +1062,9 @@ pub(crate) async fn msg_ephemeral_timer_hours(
by_contact: ContactId,
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerHours)
.await
.replace1(hours)
translated(context, StockMessage::MsgYouEphemeralTimerHours).replace1(hours)
} else {
translated(context, StockMessage::MsgEphemeralTimerHoursBy)
.await
.replace1(hours)
.replace2(&by_contact.get_stock_name(context).await)
}
@@ -1134,12 +1077,9 @@ pub(crate) async fn msg_ephemeral_timer_days(
by_contact: ContactId,
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerDays)
.await
.replace1(days)
translated(context, StockMessage::MsgYouEphemeralTimerDays).replace1(days)
} else {
translated(context, StockMessage::MsgEphemeralTimerDaysBy)
.await
.replace1(days)
.replace2(&by_contact.get_stock_name(context).await)
}
@@ -1152,112 +1092,103 @@ pub(crate) async fn msg_ephemeral_timer_weeks(
by_contact: ContactId,
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouEphemeralTimerWeeks)
.await
.replace1(weeks)
translated(context, StockMessage::MsgYouEphemeralTimerWeeks).replace1(weeks)
} else {
translated(context, StockMessage::MsgEphemeralTimerWeeksBy)
.await
.replace1(weeks)
.replace2(&by_contact.get_stock_name(context).await)
}
}
/// Stock string: `Forwarded`.
pub(crate) async fn forwarded(context: &Context) -> String {
translated(context, StockMessage::Forwarded).await
pub(crate) fn forwarded(context: &Context) -> String {
translated(context, StockMessage::Forwarded)
}
/// Stock string: `⚠️ Your provider's storage is about to exceed...`.
pub(crate) async fn quota_exceeding(context: &Context, highest_usage: u64) -> String {
pub(crate) fn quota_exceeding(context: &Context, highest_usage: u64) -> String {
translated(context, StockMessage::QuotaExceedingMsgBody)
.await
.replace1(&format!("{highest_usage}"))
.replace("%%", "%")
}
/// Stock string: `Incoming Messages`.
pub(crate) async fn incoming_messages(context: &Context) -> String {
translated(context, StockMessage::IncomingMessages).await
pub(crate) fn incoming_messages(context: &Context) -> String {
translated(context, StockMessage::IncomingMessages)
}
/// Stock string: `Outgoing Messages`.
pub(crate) async fn outgoing_messages(context: &Context) -> String {
translated(context, StockMessage::OutgoingMessages).await
pub(crate) fn outgoing_messages(context: &Context) -> String {
translated(context, StockMessage::OutgoingMessages)
}
/// Stock string: `Not connected`.
pub(crate) async fn not_connected(context: &Context) -> String {
translated(context, StockMessage::NotConnected).await
pub(crate) fn not_connected(context: &Context) -> String {
translated(context, StockMessage::NotConnected)
}
/// Stock string: `Connected`.
pub(crate) async fn connected(context: &Context) -> String {
translated(context, StockMessage::Connected).await
pub(crate) fn connected(context: &Context) -> String {
translated(context, StockMessage::Connected)
}
/// Stock string: `Connecting…`.
pub(crate) async fn connecting(context: &Context) -> String {
translated(context, StockMessage::Connecting).await
pub(crate) fn connecting(context: &Context) -> String {
translated(context, StockMessage::Connecting)
}
/// Stock string: `Updating…`.
pub(crate) async fn updating(context: &Context) -> String {
translated(context, StockMessage::Updating).await
pub(crate) fn updating(context: &Context) -> String {
translated(context, StockMessage::Updating)
}
/// Stock string: `Sending…`.
pub(crate) async fn sending(context: &Context) -> String {
translated(context, StockMessage::Sending).await
pub(crate) fn sending(context: &Context) -> String {
translated(context, StockMessage::Sending)
}
/// Stock string: `Your last message was sent successfully.`.
pub(crate) async fn last_msg_sent_successfully(context: &Context) -> String {
translated(context, StockMessage::LastMsgSentSuccessfully).await
pub(crate) fn last_msg_sent_successfully(context: &Context) -> String {
translated(context, StockMessage::LastMsgSentSuccessfully)
}
/// Stock string: `Error: %1$s…`.
/// `%1$s` will be replaced by a possibly more detailed, typically english, error description.
pub(crate) async fn error(context: &Context, error: &str) -> String {
translated(context, StockMessage::Error)
.await
.replace1(error)
pub(crate) fn error(context: &Context, error: &str) -> String {
translated(context, StockMessage::Error).replace1(error)
}
/// Stock string: `Not supported by your provider.`.
pub(crate) async fn not_supported_by_provider(context: &Context) -> String {
translated(context, StockMessage::NotSupportedByProvider).await
pub(crate) fn not_supported_by_provider(context: &Context) -> String {
translated(context, StockMessage::NotSupportedByProvider)
}
/// Stock string: `Messages`.
/// Used as a subtitle in quota context; can be plural always.
pub(crate) async fn messages(context: &Context) -> String {
translated(context, StockMessage::Messages).await
pub(crate) fn messages(context: &Context) -> String {
translated(context, StockMessage::Messages)
}
/// Stock string: `%1$s of %2$s used`.
pub(crate) async fn part_of_total_used(context: &Context, part: &str, total: &str) -> String {
pub(crate) fn part_of_total_used(context: &Context, part: &str, total: &str) -> String {
translated(context, StockMessage::PartOfTotallUsed)
.await
.replace1(part)
.replace2(total)
}
/// Stock string: `⚠️ Your email provider %1$s requires end-to-end encryption which is not setup yet. Tap to learn more.`.
pub(crate) async fn unencrypted_email(context: &Context, provider: &str) -> String {
translated(context, StockMessage::InvalidUnencryptedMail)
.await
.replace1(provider)
translated(context, StockMessage::InvalidUnencryptedMail).replace1(provider)
}
/// Stock string: `The attachment contains anonymous usage statistics, which helps us improve Delta Chat. Thank you!`
pub(crate) async fn stats_msg_body(context: &Context) -> String {
translated(context, StockMessage::StatsMsgBody).await
pub(crate) fn stats_msg_body(context: &Context) -> String {
translated(context, StockMessage::StatsMsgBody)
}
/// Stock string: `Others will only see this group after you sent a first message.`.
pub(crate) async fn new_group_send_first_message(context: &Context) -> String {
translated(context, StockMessage::NewGroupSendFirstMessage).await
pub(crate) fn new_group_send_first_message(context: &Context) -> String {
translated(context, StockMessage::NewGroupSendFirstMessage)
}
/// Text to put in the [`Qr::Backup2`] rendered SVG image.
@@ -1272,46 +1203,44 @@ pub(crate) async fn backup_transfer_qr(context: &Context) -> Result<String> {
} else {
context.get_primary_self_addr().await?
};
Ok(translated(context, StockMessage::BackupTransferQr)
.await
.replace1(&name))
Ok(translated(context, StockMessage::BackupTransferQr).replace1(&name))
}
pub(crate) async fn backup_transfer_msg_body(context: &Context) -> String {
translated(context, StockMessage::BackupTransferMsgBody).await
pub(crate) fn backup_transfer_msg_body(context: &Context) -> String {
translated(context, StockMessage::BackupTransferMsgBody)
}
/// Stock string: `Proxy Enabled`.
pub(crate) async fn proxy_enabled(context: &Context) -> String {
translated(context, StockMessage::ProxyEnabled).await
pub(crate) fn proxy_enabled(context: &Context) -> String {
translated(context, StockMessage::ProxyEnabled)
}
/// Stock string: `You are using a proxy. If you're having trouble connecting, try a different proxy.`.
pub(crate) async fn proxy_description(context: &Context) -> String {
translated(context, StockMessage::ProxyEnabledDescription).await
pub(crate) fn proxy_description(context: &Context) -> String {
translated(context, StockMessage::ProxyEnabledDescription)
}
/// Stock string: `Messages in this chat use classic email and are not encrypted.`.
pub(crate) async fn chat_unencrypted_explanation(context: &Context) -> String {
translated(context, StockMessage::ChatUnencryptedExplanation).await
pub(crate) fn chat_unencrypted_explanation(context: &Context) -> String {
translated(context, StockMessage::ChatUnencryptedExplanation)
}
/// Stock string: `You are using the legacy option "Move automatically to DeltaChat Folder`…
pub(crate) async fn mvbox_move_deprecation(context: &Context) -> String {
translated(context, StockMessage::MvboxMoveDeprecation).await
pub(crate) fn mvbox_move_deprecation(context: &Context) -> String {
translated(context, StockMessage::MvboxMoveDeprecation)
}
impl Viewtype {
/// returns Localized name for message viewtype
pub async fn to_locale_string(&self, context: &Context) -> String {
pub fn to_locale_string(&self, context: &Context) -> String {
match self {
Viewtype::Image => image(context).await,
Viewtype::Gif => gif(context).await,
Viewtype::Sticker => sticker(context).await,
Viewtype::Audio => audio(context).await,
Viewtype::Voice => voice_message(context).await,
Viewtype::Video => video(context).await,
Viewtype::File => file(context).await,
Viewtype::Image => image(context),
Viewtype::Gif => gif(context),
Viewtype::Sticker => sticker(context),
Viewtype::Audio => audio(context),
Viewtype::Voice => voice_message(context),
Viewtype::Video => video(context),
Viewtype::File => file(context),
Viewtype::Webxdc => "Mini App".to_owned(),
Viewtype::Vcard => "👤".to_string(),
// The following shouldn't normally be shown to users, so translations aren't needed.
@@ -1323,10 +1252,9 @@ impl Viewtype {
impl Context {
/// Set the stock string for the [StockMessage].
///
pub async fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
pub fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
self.translated_stockstrings
.set_stock_translation(id, stockstring)
.await?;
.set_stock_translation(id, stockstring)?;
Ok(())
}
@@ -1353,7 +1281,7 @@ impl Context {
msg.param.set(Param::Filename, "welcome-image.jpg");
chat::add_device_msg(self, Some("core-welcome-image"), Some(&mut msg)).await?;
let mut msg = Message::new_text(welcome_message(self).await);
let mut msg = Message::new_text(welcome_message(self));
chat::add_device_msg(self, Some("core-welcome"), Some(&mut msg)).await?;
Ok(())
}
@@ -1362,10 +1290,8 @@ impl Context {
impl Accounts {
/// Set the stock string for the [StockMessage].
///
pub async fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
self.stockstrings
.set_stock_translation(id, stockstring)
.await?;
pub fn set_stock_translation(&self, id: StockMessage, stockstring: String) -> Result<()> {
self.stockstrings.set_stock_translation(id, stockstring)?;
Ok(())
}
}

View File

@@ -20,9 +20,8 @@ fn test_fallback() {
async fn test_set_stock_translation() {
let t = TestContext::new().await;
t.set_stock_translation(StockMessage::NoMessages, "xyz".to_string())
.await
.unwrap();
assert_eq!(no_messages(&t).await, "xyz")
assert_eq!(no_messages(&t), "xyz")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -31,13 +30,11 @@ async fn test_set_stock_translation_wrong_replacements() {
assert!(
t.ctx
.set_stock_translation(StockMessage::NoMessages, "xyz %1$s ".to_string())
.await
.is_err()
);
assert!(
t.ctx
.set_stock_translation(StockMessage::NoMessages, "xyz %2$s ".to_string())
.await
.is_err()
);
}
@@ -45,7 +42,7 @@ async fn test_set_stock_translation_wrong_replacements() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_str() {
let t = TestContext::new().await;
assert_eq!(no_messages(&t).await, "No messages.");
assert_eq!(no_messages(&t), "No messages.");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -56,17 +53,14 @@ async fn test_stock_string_repl_str() {
.unwrap();
let contact = Contact::get_by_id(&t.ctx, contact_id).await.unwrap();
// uses %1$s substitution
assert_eq!(contact_verified(&t, &contact).await, "Someone verified.");
assert_eq!(contact_verified(&t, &contact), "Someone verified.");
// We have no string using %1$d to test...
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_simple() {
let t = TestContext::new().await;
assert_eq!(
msg_location_enabled(&t).await,
"Location streaming enabled."
)
assert_eq!(msg_location_enabled(&t), "Location streaming enabled.")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -111,7 +105,7 @@ 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).await;
let str = quota_exceeding(&t, 81);
assert!(str.contains("81% "));
assert!(str.contains("100% "));
assert!(!str.contains("%%"));

View File

@@ -98,13 +98,13 @@ impl Summary {
contact: Option<&Contact>,
) -> Result<Summary> {
let prefix = if msg.state == MessageState::OutDraft {
Some(SummaryPrefix::Draft(stock_str::draft(context).await))
Some(SummaryPrefix::Draft(stock_str::draft(context)))
} else if msg.from_id == ContactId::SELF {
if msg.is_info() || msg.viewtype == Viewtype::Call || chat.typ == Chattype::OutBroadcast
{
None
} else {
Some(SummaryPrefix::Me(stock_str::self_msg(context).await))
Some(SummaryPrefix::Me(stock_str::self_msg(context)))
}
} else if chat.typ == Chattype::Group
|| chat.typ == Chattype::Mailinglist
@@ -124,7 +124,7 @@ impl Summary {
let mut text = msg.get_summary_text(context).await;
if text.is_empty() && msg.quoted_text().is_some() {
text = stock_str::reply_noun(context).await
text = stock_str::reply_noun(context)
}
let thumbnail_path = if msg.viewtype == Viewtype::Image
@@ -160,7 +160,7 @@ impl Message {
let summary = self.get_summary_text_without_prefix(context).await;
if self.is_forwarded() {
format!("{}: {}", stock_str::forwarded(context).await, summary)
format!("{}: {}", stock_str::forwarded(context), summary)
} else {
summary
}
@@ -180,43 +180,43 @@ impl Message {
match viewtype {
Viewtype::Image => {
emoji = Some("📷");
type_name = Some(stock_str::image(context).await);
type_name = Some(stock_str::image(context));
type_file = None;
append_text = true;
}
Viewtype::Gif => {
emoji = None;
type_name = Some(stock_str::gif(context).await);
type_name = Some(stock_str::gif(context));
type_file = None;
append_text = true;
}
Viewtype::Sticker => {
emoji = None;
type_name = Some(stock_str::sticker(context).await);
type_name = Some(stock_str::sticker(context));
type_file = None;
append_text = true;
}
Viewtype::Video => {
emoji = Some("🎥");
type_name = Some(stock_str::video(context).await);
type_name = Some(stock_str::video(context));
type_file = None;
append_text = true;
}
Viewtype::Voice => {
emoji = Some("🎤");
type_name = Some(stock_str::voice_message(context).await);
type_name = Some(stock_str::voice_message(context));
type_file = None;
append_text = true;
}
Viewtype::Audio => {
emoji = Some("🎵");
type_name = Some(stock_str::audio(context).await);
type_name = Some(stock_str::audio(context));
type_file = self.get_filename();
append_text = true
}
Viewtype::File => {
emoji = Some("📎");
type_name = Some(stock_str::file(context).await);
type_name = Some(stock_str::file(context));
type_file = self.get_filename();
append_text = true
}
@@ -255,14 +255,14 @@ impl Message {
type_name = Some(match call_state {
CallState::Alerting | CallState::Active | CallState::Completed { .. } => {
if self.from_id == ContactId::SELF {
stock_str::outgoing_call(context, has_video).await
stock_str::outgoing_call(context, has_video)
} else {
stock_str::incoming_call(context, has_video).await
stock_str::incoming_call(context, has_video)
}
}
CallState::Missed => stock_str::missed_call(context).await,
CallState::Declined => stock_str::declined_call(context).await,
CallState::Canceled => stock_str::canceled_call(context).await,
CallState::Missed => stock_str::missed_call(context),
CallState::Declined => stock_str::declined_call(context),
CallState::Canceled => stock_str::canceled_call(context),
});
type_file = None;
append_text = false
@@ -270,7 +270,7 @@ impl Message {
Viewtype::Text | Viewtype::Unknown => {
emoji = None;
if self.param.get_cmd() == SystemMessage::LocationOnly {
type_name = Some(stock_str::location(context).await);
type_name = Some(stock_str::location(context));
type_file = None;
append_text = false;
} else {
@@ -490,12 +490,5 @@ mod tests {
msg.get_summary_text_without_prefix(ctx).await,
"📎 foo.bar \u{2013} bla bla"
); // skipping prefix used for reactions summaries
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(ctx, "autocrypt-setup-message.html", b"data", None)
.unwrap();
msg.param.set_cmd(SystemMessage::AutocryptSetupMessage);
assert_summary_texts(&msg, ctx, "📎 autocrypt-setup-message.html").await;
// no special handling of ASM
}
}

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