Compare commits

..

246 Commits

Author SHA1 Message Date
Hocuri
380f6e2786 Some more small things I found while self-rewiewing 2025-09-15 17:34:41 +02:00
Hocuri
43d65cb012 Small things I found while self-reviewing 2025-09-15 10:13:15 +02:00
Hocuri
8fda2dee52 Remove another unnecessary function 2025-09-11 21:52:15 +02:00
Hocuri
640d81094a Remove rarely-used function is_any_broadcast() 2025-09-11 21:49:03 +02:00
Hocuri
c5b5d8020b test: simplify a bit 2025-09-11 21:39:11 +02:00
Hocuri
dfc969e3c0 Try reverting a possibly-unnecessary change 2025-09-11 21:29:33 +02:00
Hocuri
45bed57055 test: Improve test_leave_broadcast a bit 2025-09-11 18:26:04 +02:00
Hocuri
9914233683 Remove unused function 2025-09-11 17:53:56 +02:00
Hocuri
cc54c68b29 Improve docs 2025-09-11 17:53:41 +02:00
Hocuri
e8fff886a0 test: Improve test_leave_broadcast(), fix small bugs I found along the way 2025-09-11 14:48:49 +02:00
Hocuri
632dd28e5b test: Add test_leave_broadcast, fix bugs I found along the way 2025-09-11 11:40:44 +02:00
Hocuri
ac98289728 Remove outdated TODO 2025-09-10 22:40:05 +02:00
Hocuri
23c04c2134 security: Make sure that there is no trace of a member after they left 2025-09-10 22:39:02 +02:00
Hocuri
6cd499ebc1 Accept 9 lines of code duplication in exchange for lower code complexity 2025-09-10 22:23:04 +02:00
Hocuri
abd091db4c Remove TODO
Not possible to use create_multiuser_record(), because it's nicer to do
things in a transaction here
2025-09-10 18:55:40 +02:00
Hocuri
a5d9d43d47 refactor: small renames 2025-09-10 18:51:13 +02:00
Hocuri
dca184f72c refactor: simplify create_broadcast_ex() 2025-09-10 18:42:34 +02:00
Hocuri
d967bff702 Revert debug = 'full' 2025-09-10 17:55:40 +02:00
Hocuri
de10f31a3a test: Remove old test_broadcast, which tested manually adding a member to a broadcast 2025-09-09 20:36:00 +02:00
Hocuri
dd11364bef test: fix test_broadcast(): Broadcast channels are never unpromoted 2025-09-09 20:01:03 +02:00
Hocuri
3a8a6f6949 Revert small superflous change 2025-09-09 19:59:17 +02:00
Hocuri
557702ea74 Remove outdated TODO
For `vb-member-added`:
- On Bob's second device, observe_securejoin_on_other_device() isn't
  even called, because the message is incoming, not outgoing
- On Alice's second device, Bob is added as a result of the
  `vb-request-with-auth` message, so, it's not necessary to add Bob as a
  result of the outgoing `vb-member-added` message
2025-09-09 19:57:05 +02:00
Hocuri
fc52c8de05 Merge remote-tracking branch 'origin/main' into hoc/channels-encryption-only-qrcodes 2025-09-09 19:34:37 +02:00
Hocuri
4c068e835b test: fix test_sync_broadcast() 2025-09-09 19:15:36 +02:00
iequidoo
b9ff40c6b5 test: Message is OutFailed if all keys are missing (#6849)
Follow-up to 143ba6d5e7 before which a message remained in
`OutPending` indefinitely in such a case.
2025-09-09 09:48:12 -03:00
iequidoo
0684810d38 refactor: prepare_msg_raw(): don't return MsgId
The function takes `&mut Message` and updates its `id` and `chat_id` before return.
2025-09-09 09:48:12 -03:00
Hocuri
18c84e838c Merge remote-tracking branch 'origin/main' into hoc/channels-encryption-only-qrcodes 2025-09-09 10:17:59 +02:00
Hocuri
1cc7ce6e27 api: Put the chattype into the SecurejoinInviterProgress event (#7181)
Quoting @adbenitez:

> I have been using the SecurejoinInviterProgress event to show a
welcome message when user scan the QR/link of the bot (== starts a chat
with the bot)

> but this have a big problem: in that event all you know is that a
contact completed the secure-join process, you don't know if it was via
certain 1:1 invite link or a group invitation, then a group-invite bot
would send you a help message in 1:1 every time you join a group with it

Since it's easy enough to add this information to the
SecurejoinInviterProgress event, I wrote a PR to do so.
2025-09-09 08:17:53 +00:00
link2xt
82bc1bf0b1 refactor: use recv_msg_trash() instead of recv_msg_opt() 2025-09-09 06:08:15 +00:00
link2xt
75bcf8660b chore(release): prepare for 2.13.0 2025-09-09 05:46:13 +00:00
dependabot[bot]
5e1d945198 chore(deps): bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 02:11:35 +00:00
dependabot[bot]
e047184ede chore(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 02:11:26 +00:00
Hocuri
f8a46fe3cf test(python): Extend test_qr_securejoin_broadcast and make it less flaky 2025-09-08 22:29:04 +02:00
link2xt
307a2eb6ec feat: withdraw all QR codes when one is withdrawn
This is a preparation for expiring authentication tokens.

If we make authentication token expire,
we need to generate new authentication tokens each time
QR code screen is opened in the UI,
so authentication token is fresh.
We however don't want to completely invalidate
old authentication codes at the same time,
e.g. they should still be valid for joining groups,
just not result in a verification on the inviter side.

Since a group now can have a lot of authentication tokens,
it is easy to lose track of them
without any way to remove them
as they are not displayed anywhere in the UI.
As a solution, we now remove all
tokens corresponding to a group ID
when one token is withdrawn,
or all non-group tokens
when a single non-group token is withdrawn.

"Reset QR code" option already present
in the UI which works by resetting
current QR code will work without any UI changes,
but will now result in invalidation
of all previously created QR codes and invite links.
2025-09-08 17:51:45 +00:00
bjoern
ab8aedf06e refine call states (#7179)
- sync declined calls from callee to caller, as usual in all larger
messengers
- introduce the call states "Missed call", "Declined call" and
"Cancelled all" ("Ended call" is gone)
- allow calling end_call()/accept_call() for already ended/accepted
calls, in practise, handling all cornercases is tricky in UI - and the
state needs anyways to be tracked.
- track and show the call duration

the duration calculation depends on local time, but it is displayed only
coarse and is not needed for any state. this can be improved as needed,
timestamps of the corresponding messages are probably better at some
point. or ending device sends its view of the time around. but for the
first throw, it seems good enough

if we finally want that set of states, it can be exposed to a json-info
in a subsequent call, so that the UI can render it more nicely. fallback
strings as follows will stay for now to make adaption in other UI easy,
and for debugging:

<img width="320" alt="IMG_0154"
src="https://github.com/user-attachments/assets/09a89bfb-66f4-4184-b05c-e8040b96cf44"
/>

successor of https://github.com/chatmail/core/pull/6650
2025-09-08 15:48:35 +02:00
Hocuri
302059cd63 clippy 2025-09-05 22:25:39 +02:00
Hocuri
ae4b0fdb4e Adapt golden tests to the fact that 'Messages are end-to-end encrypted.' is always added now 2025-09-05 22:21:43 +02:00
Hocuri
b5a54aa6cf fix: Scaleup contact on securejoin, send more events, use correct create_blocked 2025-09-05 21:52:25 +02:00
Hocuri
01d9acbf6a test_qr_securejoin_broadcast(): Test a few more things 2025-09-05 21:52:25 +02:00
Hocuri
60e4899b3a test: Add python test test_qr_securejoin_broadcast, and fix some small bugs I found on the way 2025-09-05 21:52:25 +02:00
Hocuri
8eb5fc528f Adapt to things that changed when I rebased 2025-09-05 21:52:25 +02:00
Hocuri
286f913f6e refactor: No need for observe_securejoin_on_other_device() for securejoin v2 2025-09-05 21:52:25 +02:00
Hocuri
6e68eb1c5d Resolve identity-misbinding TODO 2025-09-05 21:52:25 +02:00
Hocuri
153ced7141 Remove outdated TODO 2025-09-05 21:52:25 +02:00
Hocuri
4a9af2b600 refactor: Remove superflous check for ChatGroupMemberAdded 2025-09-05 21:52:25 +02:00
Hocuri
0c25646ac2 test: Add golden test for Alice's side, too, in test_sync_broadcast 2025-09-05 21:52:25 +02:00
Hocuri
019da70c8a test: When a golden test fails, print some extra info 2025-09-05 21:52:19 +02:00
bjoern
b6ab13f1de feat: hide call status change messages (#7175)
this PR uses the initial "call messages" (that has a separate viewtype
since #7174) to show all call status.

this is what most other messengers are doing as well. additional "info
messages" after a call are no longer needed.

on the wire, as we cannot pickpack on visible info messages, we use
hidden messages, similar to eg. webxdc status updates.

in future PR, it is planned to allow getting call state as a json, so
that UI can render nicely. it is then decided if we want to translate
the strings in the core.

<img width="320" alt="IMG_0150"
src="https://github.com/user-attachments/assets/41ee3fa3-8be4-42c3-8dd9-d20f49881650"
/>

successor of https://github.com/chatmail/core/pull/6650
2025-09-05 08:52:15 +02:00
link2xt
53a3e51920 feat: support receiving Autocrypt-Gossip with _verified attribute
This commit is a preparation for
sending Autocrypt-Gossip with `_verified` attribute
instead of `Chat-Verified` header.
2025-09-04 19:46:14 +00:00
link2xt
4033566b4a refactor: remove Aheader::new 2025-09-04 19:46:14 +00:00
bjoern
bed1623dcb feat: use dedicated 'call' viewtype (#7174)
a dedicated viewtype allows the UI to show a more advanced UI, but even
when using the defaults,
it has the advantage that incoming/outgoing and the date are directly
visible.

successor of https://github.com/chatmail/core/pull/6650
2025-09-04 16:51:51 +02:00
link2xt
d4704977bc api!: remove e2ee_enabled preference
The setting is already removed from the UIs,
but users who had it disabled previously have
no way to enable it. After this change
encryption is effectively always preferred.
2025-09-04 13:58:05 +00:00
link2xt
838eed94bc chore: update provider database 2025-09-04 13:58:05 +00:00
link2xt
9870725d1f refactor: remove unused EncryptPreference::Reset 2025-09-04 13:58:05 +00:00
link2xt
ba827283be docs(STYLE.md): prefer BTreeMap and BTreeSet over hash variants 2025-09-04 12:26:50 +00:00
dependabot[bot]
1e37cb8c3c chore(cargo): bump nu-ansi-term from 0.46.0 to 0.50.1
Bumps [nu-ansi-term](https://github.com/nushell/nu-ansi-term) from 0.46.0 to 0.50.1.
- [Release notes](https://github.com/nushell/nu-ansi-term/releases)
- [Changelog](https://github.com/nushell/nu-ansi-term/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nushell/nu-ansi-term/compare/v0.46.0...v0.50.1)

---
updated-dependencies:
- dependency-name: nu-ansi-term
  dependency-version: 0.50.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-03 13:17:03 -03:00
Hocuri
19159c905f test: Rename alice0, alice1 to alice1, alice2 in test_sync_muted()
This makes the automatically generated "alice, alice2" context names
correct
2025-09-03 17:56:15 +02:00
Hocuri
51a36d23a2 small refactoring 2025-09-03 17:56:14 +02:00
Hocuri
f7844e97c2 fix: Don't show wrong system message on Bob's second device
Before this, Bob's second device showed a system message
"⚠️ It seems you are using Delta Chat on multiple devices that cannot
decrypt each other's outgoing messages..."
2025-09-03 17:56:14 +02:00
Hocuri
a3d1e3bc89 Remove TODO 2025-09-03 17:56:14 +02:00
Hocuri
dc5237f530 fix: Remove panic!() call 2025-09-03 17:56:14 +02:00
Hocuri
f66f6f3e92 refactor: Rename to symm_encrypt_message() 2025-09-03 17:56:14 +02:00
Hocuri
9b49386bc8 fix: Protect against DOS attacks via a message with many esks using expensive-to-compute s2k algos 2025-09-03 17:56:14 +02:00
Hocuri
40f4eea049 feat: Sync Alice's verification on Bob's side 2025-09-03 17:56:14 +02:00
Hocuri
00ba559562 Resolve some small TODOs 2025-09-03 17:56:14 +02:00
Hocuri
3a648698ee resolve some small TODOs 2025-09-03 17:56:14 +02:00
Hocuri
61e0d14eed refactor: Remove small code duplication 2025-09-03 17:56:14 +02:00
Hocuri
2efbbcc669 bench: Improve benchmark_decrypting.rs benchmark 2025-09-03 17:56:13 +02:00
Hocuri
479a5632fb feat: Make reacting to v2 invites generic over the type of the invite (contact/group/broadcast) 2025-09-03 17:56:13 +02:00
Hocuri
9dc590cb35 feat: Rename vb-request-v2 -> vb-request-with-auth
Turns out that Alice reacts to a request-v2 message in exactly the same
way as to a request-with-auth message. So, no need to distinguish here.
2025-09-03 17:56:13 +02:00
Hocuri
956519cd98 fix: Make sure that only the channel owner can write into the chat 2025-09-03 17:56:13 +02:00
Hocuri
90d4856a1c comments/naming: Make sure that I consistently use shared_secret 2025-09-03 17:56:13 +02:00
Hocuri
792c05fc3e fix: Don't show a weird 'Secure-Join: vb-request-v2 message' in Alice's 1:1 chat a recipient 2025-09-03 17:56:13 +02:00
Hocuri
3cf7746ceb Remove unnecessary TODO 2025-09-03 17:56:13 +02:00
Hocuri
0acc34a882 Notify a removed member that they were removed 2025-09-03 17:56:13 +02:00
Hocuri
378896eca3 docs: Fix wrong comment on msg_del_member_local() 2025-09-03 17:56:13 +02:00
Hocuri
265ac4e30b fix: Show only one member-added message for Bob 2025-09-03 17:56:12 +02:00
Hocuri
8d89dcc65f Add golden test that only one member-added message is shown for Bob 2025-09-03 17:56:12 +02:00
Hocuri
a858709301 Use translatable message for broadcast-joining 2025-09-03 17:56:12 +02:00
Hocuri
3d5e97eced No clippy warnings anymore! 2025-09-03 17:56:12 +02:00
Hocuri
5da6ca1ec4 test: Improve test_send_avatar_in_securejoin() 2025-09-03 17:56:12 +02:00
Hocuri
58d0fd39b5 clippy 2025-09-03 17:56:12 +02:00
Hocuri
40e3c34f59 refactor: It's not actually necessary for Alice to remember how the message was encrypted 2025-09-03 17:56:12 +02:00
Hocuri
1377a77ea8 refactor: Use the same decode_name() function for the contact name, remove redundant check for grpid.is_some()
If grpid is none, the group/brodacast name isn't used, anyways
2025-09-03 17:56:11 +02:00
Hocuri
db32f1142c Don't include the broadcast's shared secret in the QR code 2025-09-03 17:56:11 +02:00
Hocuri
738f6c1799 feat: Transfer the broadcast secret in an encrypted message rather than directly in the QR code 2025-09-03 17:56:11 +02:00
Hocuri
e1abaebeb5 WIP, untested: Receiving side of passing broadcast secret in a message 2025-09-03 17:56:11 +02:00
Hocuri
0978a46ab6 WIP, untested: Sending side of transferring the secret in member-added message 2025-09-03 17:56:11 +02:00
Hocuri
410048a9e1 Improve TODOs 2025-09-03 17:56:11 +02:00
Hocuri
72336ebb8a Add benchmark for message decryption 2025-09-03 17:56:11 +02:00
Hocuri
fca8948e4c Speed up message decryption by not iterating in the s2k algorithm
The passphrase has as much entropy as the session key, so, there is no
point in making the computation slow by iterating.
2025-09-03 17:56:11 +02:00
Hocuri
d431f2ebd3 Add benchmark for message decryption 2025-09-03 17:56:10 +02:00
Hocuri
ad0e3179dd Remove unused and problematic ensure!
`secret_keys.is_empty()` only checked whether any secret keys were
passed. This is not helpful, and made decrypting fail in the benchmark.
2025-09-03 17:56:10 +02:00
Hocuri
494ad63a73 feat: Increase secret size to 256 bits of entropy
This is for quantumn computers. When trying to break AES, quantumn
computers give a square-root speedup, i.e. the 144 bits of entropy would
take as many queries as breaking 72 bits of entropy on a normal computer. This neglects
e.g. the costs of quantumn circuits and quantumn error correction [1], so,
144 bits entropy would actually have been fine, but in order to be on
the very safe side and so that noone can complain, let's increase it to
256 bits.

[1]: https://csrc.nist.gov/csrc/media/Events/2024/fifth-pqc-standardization-conference/documents/papers/on-practical-cost-of-grover.pdf
2025-09-03 17:56:10 +02:00
Hocuri
13bbcbeb0e Add some print statements for debugging 2025-09-03 17:56:10 +02:00
Hocuri
a14b53e3ca fix: Don't show a weird 'vb-request-with-auth' message when a subscriber joins 2025-09-03 17:56:10 +02:00
Hocuri
9474fbff56 fix: Correct member-added info messages 2025-09-03 17:56:10 +02:00
Hocuri
c4001cc3ff fix: Let Alice send vb-member-added so that the chat is immediately shown on Bob's device 2025-09-03 17:56:08 +02:00
Hocuri
548f5a454c Add TODO 2025-09-03 17:55:40 +02:00
Hocuri
91110147c3 fix: Actually send broadcast message to recipients, ALL TESTS PASS NOW - fix test_broadcasts_name_and_avatar(). 2025-09-03 17:55:40 +02:00
Hocuri
6012595f1a test: fix test_encrypt_decrypt_broadcast() 2025-09-03 17:55:40 +02:00
Hocuri
504b2d691d test: fix test_leave_broadcast 2025-09-03 17:55:40 +02:00
Hocuri
7e191f6cf9 fix: Make joining a channel work with multi-device, fix test_leave_broadcast_multidevice 2025-09-03 17:55:40 +02:00
Hocuri
37f6da1cc9 test: Fix one panic in test_broadcasts_name_and_avatar, but there is another one where I couldn't find the problem 2025-09-03 17:55:39 +02:00
Hocuri
df2693f307 test: Fix test_broadcast_multidev 2025-09-03 17:55:39 +02:00
Hocuri
cdd280a2d3 make test_block_broadcast pass 2025-09-03 17:55:39 +02:00
Hocuri
6bb714a6e5 fix: Make syncing of QR tokens work, make test_sync_broadcast pass 2025-09-03 17:55:39 +02:00
Hocuri
b276eda1a2 Make basic multi-device work on joiner side, fix test_only_minimal_data_are_forwarded 2025-09-03 17:55:39 +02:00
Hocuri
9c747b4cb0 fix: make test_broadcast work, return an error when trying to add manually add a contact to a broadcast list, don't have unpromoted broadcast lists, make basic multi-device, inviter side, work 2025-09-03 17:55:39 +02:00
Hocuri
326deab025 Broadcast-securejoin is working!! 2025-09-03 17:55:39 +02:00
Hocuri
24561cd256 test: Add test_send_avatar_in_securejoin 2025-09-03 17:55:39 +02:00
Hocuri
5da7e45b2b Adapt the rest of the code to the new QR code type 2025-09-03 17:55:39 +02:00
Hocuri
3389e93820 feat: Add broadcast QR type (todo: documentation) 2025-09-03 17:55:39 +02:00
Hocuri
789b923bb8 feat: Store symmetric key non-redundantly in the database 2025-09-03 17:55:39 +02:00
Hocuri
547f750073 Make it compile 2025-09-03 17:55:39 +02:00
Hocuri
382023de11 sync broadcast secret for multidevice 2025-09-03 17:55:39 +02:00
Hocuri
3781a35989 feat: Add create_broadcast_shared_secret() 2025-09-03 17:55:39 +02:00
Hocuri
8653fdbd8e feat: Save the secret to encrypt and decrypt messages. Next: Send it in a 'member added' message. 2025-09-03 17:55:38 +02:00
Hocuri
47bf4da1fe WIP: Start with decryption, and a test for it. Next TODO: SQL table migartion. 2025-09-03 17:55:38 +02:00
Hocuri
ec2056f5e2 feat: Symmetric encryption. No decryption, no sharing of the secret, not tested. 2025-09-03 17:55:35 +02:00
dependabot[bot]
1991e01641 Merge pull request #7157 from chatmail/dependabot/cargo/tempfile-3.21.0 2025-09-02 23:29:24 +00:00
dependabot[bot]
d7e87b6336 Merge pull request #7152 from chatmail/dependabot/cargo/syn-2.0.106 2025-09-02 23:18:00 +00:00
dependabot[bot]
fde490ba15 chore(cargo): bump tempfile from 3.20.0 to 3.21.0
Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.20.0 to 3.21.0.
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/compare/v3.20.0...v3.21.0)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-version: 3.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 22:01:42 +00:00
dependabot[bot]
cf5a16d967 chore(cargo): bump syn from 2.0.104 to 2.0.106
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.104 to 2.0.106.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.104...2.0.106)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 2.0.106
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 22:01:28 +00:00
dependabot[bot]
e8dde9c63d chore(cargo): bump thiserror from 2.0.12 to 2.0.16
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 2.0.12 to 2.0.16.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.12...2.0.16)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 22:00:03 +00:00
dependabot[bot]
667a935665 chore(cargo): bump serde_json from 1.0.142 to 1.0.143
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.142 to 1.0.143.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.142...v1.0.143)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.143
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 21:34:37 +00:00
dependabot[bot]
28cea706fa chore(cargo): bump anyhow from 1.0.98 to 1.0.99
Bumps [anyhow](https://github.com/dtolnay/anyhow) from 1.0.98 to 1.0.99.
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.98...1.0.99)

---
updated-dependencies:
- dependency-name: anyhow
  dependency-version: 1.0.99
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 21:20:26 +00:00
dependabot[bot]
209a990444 chore(cargo): bump libc from 0.2.174 to 0.2.175
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.174 to 0.2.175.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.175/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.174...0.2.175)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.175
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 21:19:06 +00:00
dependabot[bot]
6365a46fac chore(cargo): bump percent-encoding from 2.3.1 to 2.3.2
Bumps [percent-encoding](https://github.com/servo/rust-url) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/servo/rust-url/releases)
- [Commits](https://github.com/servo/rust-url/commits)

---
updated-dependencies:
- dependency-name: percent-encoding
  dependency-version: 2.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 21:18:44 +00:00
dependabot[bot]
a81496e9ab Merge pull request #7150 from chatmail/dependabot/cargo/quick-xml-0.38.3 2025-09-02 18:56:21 +00:00
dependabot[bot]
ca05733b9d Merge pull request #7151 from chatmail/dependabot/cargo/toml-0.9.5 2025-09-02 18:55:42 +00:00
dependabot[bot]
dfb5348a78 Merge pull request #7156 from chatmail/dependabot/cargo/brotli-8.0.2 2025-09-02 18:55:16 +00:00
dependabot[bot]
602e52490c Merge pull request #7158 from chatmail/dependabot/cargo/async_zip-0.0.18 2025-09-02 18:54:53 +00:00
dependabot[bot]
740b24e8a4 Merge pull request #7160 from chatmail/dependabot/cargo/futures-lite-2.6.1 2025-09-02 18:53:52 +00:00
dependabot[bot]
44a09ffd12 Merge pull request #7164 from chatmail/dependabot/cargo/hyper-1.7.0 2025-09-02 18:49:24 +00:00
dependabot[bot]
054c42cbc2 Merge pull request #7159 from chatmail/dependabot/cargo/uuid-1.18.0 2025-09-02 18:48:37 +00:00
dependabot[bot]
34263a70e2 Merge pull request #7162 from chatmail/dependabot/cargo/tokio-util-0.7.16 2025-09-02 18:48:05 +00:00
link2xt
7ea6ca35d7 feat: do not replace messages with an error on verification failure 2025-09-02 18:29:53 +00:00
link2xt
a9aad497fc api!: remove deprecated is_protection_broken 2025-09-02 18:29:53 +00:00
link2xt
7da8489635 api!: remove is_profile_verified APIs
UIs now display green checkmark in a profile
if the contact is verified.
Chats with key-contacts cannot become unprotected,
so there is no need to check 1:1 chat.
2025-09-02 18:29:53 +00:00
link2xt
683561374d test: add TestContext.create_chat_id 2025-09-02 18:29:53 +00:00
link2xt
66c9982822 fix: add "Messages are end-to-end encrypted." to non-protected groups
The messages are end-to-end encrypted
in encrypted group regardless
of whether the group is protected or not.
2025-09-02 18:29:53 +00:00
link2xt
1b6450b210 feat: do not set "unknown sender for this chat" error 2025-09-02 18:29:53 +00:00
dependabot[bot]
aa8a13adb2 chore(cargo): bump hyper from 1.6.0 to 1.7.0
Bumps [hyper](https://github.com/hyperium/hyper) from 1.6.0 to 1.7.0.
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.6.0...v1.7.0)

---
updated-dependencies:
- dependency-name: hyper
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 17:51:13 +00:00
dependabot[bot]
5888541c05 chore(cargo): bump tokio-util from 0.7.14 to 0.7.16
Bumps [tokio-util](https://github.com/tokio-rs/tokio) from 0.7.14 to 0.7.16.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-util-0.7.14...tokio-util-0.7.16)

---
updated-dependencies:
- dependency-name: tokio-util
  dependency-version: 0.7.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 17:08:06 +00:00
dependabot[bot]
f893487dc0 chore(cargo): bump futures-lite from 2.6.0 to 2.6.1
Bumps [futures-lite](https://github.com/smol-rs/futures-lite) from 2.6.0 to 2.6.1.
- [Release notes](https://github.com/smol-rs/futures-lite/releases)
- [Changelog](https://github.com/smol-rs/futures-lite/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smol-rs/futures-lite/compare/v2.6.0...v2.6.1)

---
updated-dependencies:
- dependency-name: futures-lite
  dependency-version: 2.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 17:02:54 +00:00
dependabot[bot]
b84beaf974 chore(cargo): bump uuid from 1.17.0 to 1.18.0
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.17.0...v1.18.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 16:37:29 +00:00
dependabot[bot]
75a3c55e70 chore(cargo): bump async_zip from 0.0.17 to 0.0.18
Bumps [async_zip](https://github.com/Majored/rs-async-zip) from 0.0.17 to 0.0.18.
- [Commits](https://github.com/Majored/rs-async-zip/compare/v0.0.17...v0.0.18)

---
updated-dependencies:
- dependency-name: async_zip
  dependency-version: 0.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 15:57:03 +00:00
dependabot[bot]
854a09e12f chore(cargo): bump brotli from 8.0.1 to 8.0.2
Bumps [brotli](https://github.com/dropbox/rust-brotli) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/dropbox/rust-brotli/releases)
- [Commits](https://github.com/dropbox/rust-brotli/commits/8.0.2)

---
updated-dependencies:
- dependency-name: brotli
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 15:40:32 +00:00
dependabot[bot]
40412fd4a9 chore(cargo): bump toml from 0.9.4 to 0.9.5
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.4 to 0.9.5.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.4...toml-v0.9.5)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 14:32:01 +00:00
dependabot[bot]
57fc084795 chore(cargo): bump quick-xml from 0.37.5 to 0.38.3
Bumps [quick-xml](https://github.com/tafia/quick-xml) from 0.37.5 to 0.38.3.
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.37.5...v0.38.3)

---
updated-dependencies:
- dependency-name: quick-xml
  dependency-version: 0.38.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 14:25:16 +00:00
Nico de Haen
143ba6d5e7 fix: Mark message as failed if it can't be send (#7143)
resolves #6849
2025-09-02 14:32:25 +02:00
bjoern
6b338a923c feat: warn for outdated versions after 6 months (#7144)
3 months were proven to be too short some years ago, after that issue,
we went far up to 12 months.
however, 12 months were considered too long after recent discussions :)
so, 6 months seems to be a good compromise.

the warning is still repeated every months and the text is unchanged.

advantage is still that this approach does not require network or
opt-in, and catches really all lazy updaters with few effort, cmp
https://github.com/deltachat/deltachat-desktop/issues/5422
2025-09-01 17:44:54 +02:00
iequidoo
e6ab1e3df5 fix: Update verifier_id if it's "unknown" and new verifier has known verifier
Now that the previous commit avoids creating incorrect reverse verification chains, we can do
this. Sure, existing users' dbs aready have verification chains ending with "unknown" roots, but at
least for new users updating `verifier_id` to a known verifier makes sense.
2025-09-01 05:09:19 -03:00
iequidoo
5da6976bf9 fix: Don't verify contacts by others having an unknown verifier
If this happens, mark the contact as verified by an unknown contact instead. This avoids introducing
incorrect reverse chains: if the verifier itself has an unknown verifier, it may be `contact_id`
actually (directly or indirectly) on the other device (which is needed for getting "verified by
unknown contact" in the first place).
2025-09-01 05:09:19 -03:00
iequidoo
bd15d90e77 refactor: Check that verifier is verified in turn 2025-09-01 05:09:19 -03:00
iequidoo
61633cf23b fix: Don't reverify contacts by SELF on receipt of a message from another device
Also verify not yet verified contacts w/o setting a verifier for them (in the db it's stored as
`verifier_id=id` though) because we don't know who verified them for another device.
2025-09-01 05:09:19 -03:00
iequidoo
9f1107c0e7 docs: Fix for SecurejoinInviterProgress with progress == 600 2025-09-01 03:57:51 -03:00
Hocuri
ff0d5ce179 test: Add another TimeShiftFalsePositiveNote (#7142)
test_maybe_warn_on_outdated() can also fail when run with `cargo test`,
rather than `cargo nextest` (just happened to me)
2025-08-31 19:32:30 +00:00
bjoern
0bbd910883 feat: add call ringing API (#6650)
this PR adds a "ringing" api that can be used for calls later.

see deltachat.h for details about the API; jsonrpc is left out until
things are settled for the needs of android/iOS

UI using this PR already successfully are
https://github.com/deltachat/deltachat-ios/pull/2638 and
https://github.com/deltachat/deltachat-android/pull/3785 ; the "payload"
passed forth and back is optimised for
https://github.com/deltachat/calls-webapp

---------

Co-authored-by: l <link2xt@testrun.org>
2025-08-30 23:48:38 +02:00
dependabot[bot]
4258088fb4 chore(cargo): bump tracing-subscriber from 0.3.19 to 0.3.20
Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.19 to 0.3.20.
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.19...tracing-subscriber-0.3.20)

---
updated-dependencies:
- dependency-name: tracing-subscriber
  dependency-version: 0.3.20
  dependency-type: direct:production
...

Co-authored-by: iequidoo <dgreshilov@gmail.com>
2025-08-30 14:21:08 -03:00
link2xt
6372b677d2 chore(release): prepare for 2.12.0 2025-08-26 21:22:39 +00:00
link2xt
9af00af70f docs: remove the comment about Color Vision Deficiency correction
Color Vision Deficiency correction has been removed from https://xmpp.org/extensions/xep-0392.html
in version 0.8.0, see the reasoning there.
2025-08-26 21:14:08 +00:00
link2xt
4010c60e7b feat: use key fingerprints for color generation
This way contact colors stays the same
even if the address changes later.
2025-08-26 21:14:08 +00:00
link2xt
aaa83a8f52 feat: replace HSLuv colors with OKLCh 2025-08-26 21:14:08 +00:00
link2xt
776408c564 fix: do not create a group if the sender includes self in the To field 2025-08-26 18:06:17 +00:00
iequidoo
d0cb2110e6 feat: Chat::get_color(): Use grpid, if present, instead of name
While testing the previous commit i understood that it's better to try giving different colors to
groups, particularly if their names are equal so that they visually differ, and at the same time
preserve the color if the group is renamed. Using `grpid` solves this. So let groups change colors
once and forever.
2025-08-24 12:10:54 -03:00
iequidoo
11e3480fe8 feat: create_group_ex(): Log and replace invalid chat name with "…"
We can't just fail on an invalid chat name because the user would lose the work already done in the
UI like selecting members. Sometimes happens to me when i put space into name.
2025-08-24 12:10:54 -03:00
Hocuri
2cd54b72b0 refactor: Make ConnectivityStore use a non-async lock (#7129)
Follow-up to https://github.com/chatmail/core/pull/7125: We now have a
mix of non-async (parking_lot) and async (tokio) Mutexes used for the
connectivity. We can just use non-async Mutexes, because we don't
attempt to hold them over an await point. I also tested that we get a
compiler error if we do try to hold one over an await point (rather than
just deadlocking/blocking the executor on runtime).

Not 100% sure about using the parking_lot rather than std Mutex, because
since https://github.com/rust-lang/rust/issues/93740, parking_lot
doesn't have a lot of advantages anymore. But as long as iroh depends on
it, we might as well use it ourselves.
2025-08-23 21:08:17 +02:00
iequidoo
c34ccafb2e fix: Make reaction message hidden only if there are no other parts
RFC 9078 "Reaction: ..." doesn't forbid messages with reactions to have other parts, so be prepared
for this.
2025-08-22 17:32:55 -03:00
iequidoo
6837874d43 fix: get_connectivity(): Get rid of locking SchedulerState::inner (#7124)
`get_connectivity()` is expected to return immediately, not when the scheduler finishes updating its
state in `start_io()/stop_io()/pause_io()`, otherwise it causes app non-responsiveness.

Instead of read-locking `SchedulerState::inner`, store the `ConnectivityStore` collection in
`Context` and fetch it from there in `get_connectivity()`. Update it every time we release a write
lock on `SchedulerState::inner`.
2025-08-22 14:18:30 -03:00
link2xt
3656337d41 refactor: do not return Result from valid_signature_fingerprints()
This function never fails.
2025-08-18 07:47:34 +00:00
link2xt
a89b6321f1 feat: assign messages to key-contacts based on Issuer Fingerprint 2025-08-16 23:22:35 +00:00
link2xt
ac10103b18 api!(python): remove remaining broken API for reactions
CFFI for reactions has been previously deprecated and removed,
so legacy python bindings using it don't work anymore.
2025-08-16 19:50:04 +00:00
iequidoo
b696a242fc feat: wal_checkpoint(): Do wal_checkpoint(PASSIVE) and wal_checkpoint(FULL) before wal_checkpoint(TRUNCATE)
This way the subsequent `wal_checkpoint(TRUNCATE)` is faster. We don't want to block writers and
readers for a long period.
2025-08-15 14:00:41 -03:00
link2xt
7e4822c8ca fix: do not reverify already verified contacts via gossip
If the contact is already introduced by someone,
usually by adding to a verified group,
it should not be reverified because of another
chat message is a verified group.
This usually results is verification loops
and is not meaningful because the verifier
likely got this same contact introduced
in the same group.
2025-08-14 15:19:09 +00:00
link2xt
a955cb5400 docs: remove broken link from documentation comments
There are many servers by now so it is not
just a workaround for one server or location.
Stated reason is clear enough without
pointing to an issue.
2025-08-13 07:03:45 +00:00
link2xt
2e2cfc4cb3 chore(release): prepare for 2.11.0 2025-08-13 00:40:18 +00:00
iequidoo
4157d1986f fix: Add messages that can't be verified as DownloadState::Available (#7059)
We haven't dropped verified groups yet, so we need to do smth with messages that can't be verified
yet which often occurs because of messages reordering, particularly in large groups. Apart from the
reported case #7059, i had other direct reports that sometimes messages can't be verified for
various reasons, but when the reason is already fixed, it should be possible to re-download failed
messages and see them.

Also remove the code replacing the message text with a verification error from
`apply_group_changes()` as `add_parts()` already does this.
2025-08-12 20:52:14 -03:00
iequidoo
d13eb2f580 feat: receive_imf::add_parts(): Get rid of extra Chat::load_from_db() calls 2025-08-12 20:52:14 -03:00
link2xt
5476f69179 fix: don't break long group names with non-ASCII characters
The fix is in mail-builder 0.4.4.
2025-08-12 23:50:58 +00:00
dependabot[bot]
dcdf30da35 Merge pull request #7103 from chatmail/dependabot/github_actions/actions/download-artifact-5 2025-08-12 23:00:40 +00:00
dependabot[bot]
55746c8c19 Merge pull request #7104 from chatmail/dependabot/github_actions/actions/checkout-5 2025-08-12 23:00:03 +00:00
iequidoo
dbdf5f2746 feat: get_securejoin_qr(): Log error if group doesn't have grpid
This doesn't fix anything in UIs currently because they don't call `get_securejoin_qr()` for
unencrypted groups, but it's still better to log an error which will be shown in this case.
2025-08-12 19:59:00 -03:00
iequidoo
b4e28deed3 feat: lookup_key_contact_by_address(): Allow looking up ContactId::SELF without chat id
This doesn't fix anything currently, but let's allow such lookups to avoid future bugs.
2025-08-12 19:59:00 -03:00
iequidoo
f4a604dcfb refactor: Chat::is_encrypted(): Make one query instead of two for 1:1 chats 2025-08-12 19:59:00 -03:00
Hocuri
b3c5787ec8 test: Log the number of the test account if there are multiple alices (#7087)
In order to debug some test failures wrt broadcast channels, I need to
read the log of some tests that have an alice0 and an alice1.

It's currently not possible to tell whether a line was logged by alice0
or alice1, so, I would like to change that with this PR.

Edit: Turns out that there are more tests that call their profiles
"alice1"/"alice2" (or "alice"/"alice2") than "alice0"/"alice1". So, I
changed the logging to count "alice", "alice2", "alice3", ….

Not sure whether I should adapt old tests; it might save someone some
confusion in the future, but I might also make a mistake and make it so
that the test doesn't properly test anymore.
2025-08-12 08:51:23 +00:00
dependabot[bot]
471d0469dd chore(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 08:21:41 +00:00
dependabot[bot]
113eda575f chore(deps): bump actions/download-artifact from 4 to 5
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 05:32:37 +00:00
link2xt
45f1da82fe fix: take Chat-Group-Name into account when matching ad hoc groups 2025-08-12 01:10:49 +00:00
link2xt
5f45ff77e4 chore: typo fix 2025-08-12 01:10:49 +00:00
link2xt
1c0201ee3d fix: assign messages to a group if there is a Chat-Group-Name
Otherwise messages sent to small groups
with only two members are incorrectly assigned
to 1:1 chat.
2025-08-12 01:10:49 +00:00
link2xt
c7340e04ec feat: do not require resent messages to be from the same chat
Chat was only loaded to avoid removing GuaranteeE2ee
for protected chats, but resending a message
in protected chat is guaranteed to be encrypted anyway.
2025-08-11 20:11:43 +00:00
link2xt
0a32476dc5 fix: do not reset GuaranteeE2ee in the database when resending messages
Otherwise if the message is loaded by the UI
after GuaranteeE2ee is reset but before SMTP queue item
is created, the message may appear as unencrypted
even if it was actually resent as encrypted.
2025-08-11 20:11:43 +00:00
link2xt
e02bc6ffb5 ci: update Rust to 1.89.0 2025-08-11 19:40:07 +00:00
link2xt
f41a3970b2 fix: do not add key-contacts to unencrypted groups
Encrypted message may create unencrypted groups
if the message does not have a Chat-Group-ID.
This can happen if v1 client sends an encrypted
message to opportunistically encrypted ad hoc group.
In this case `from_id` corresponds to the key-contact,
but we should add address-contact of the sender
to the member list.
2025-08-11 10:33:27 +00:00
bjoern
6c536f3a9b fix: log and set imex progress error (#7091)
IMEX_PROGRESS(0) event is fired in case of errors, however, the last
error was not set in this case.

this is similar to the fix at #4195
and improves the error shown in the dialog for android and iOS; desktop
does not show an error dialog at all.

<img width="320" alt="IMG_9995"
src="https://github.com/user-attachments/assets/7065fc3d-3f30-4691-b1b2-1950564a25e2"
/>

relates to https://github.com/deltachat/deltachat-android/issues/3533
2025-08-09 01:17:27 +02:00
link2xt
4b24b6a848 refactor: skip loading the contact of 1:1 unencrypted chat to show the avatar
If the chat is unencrypted, it gets unencrypted icon.
There is no need to load the address-contact to get the same icon.
2025-08-08 19:08:23 +00:00
link2xt
5f254a929f refactor: rename icon-address-contact to icon-unencrypted
The icon is mainly used to identify unencrypted chats
in the chatlist where encrypted and unencrypted chats are mixed.
It is used for group chats rather than only for 1:1 chats
with address-contacts.
2025-08-08 19:08:23 +00:00
B. Petersen
8df1a01ace feat: better string when using disappearing messages of one year (365..367 days, so it can be tweaked later) 2025-08-08 18:45:35 +00:00
link2xt
27b5ffb34f fix: do not remove query parameters from URLs
The fix is similar to the one done in
4ca0ce2fb2,
but now extended to requests other than empty POST requests as well.
2025-08-07 14:49:18 +00:00
link2xt
80af012962 fix: set correct sent_timestamp for saved outgoing messages 2025-08-07 07:58:53 +00:00
iequidoo
615c80bef4 feat: Contact::lookup_id_by_addr_ex: Prefer returning accepted contacts
We don't want to prefer returning verified contacts because e.g. if a bot was reinstalled and its
key changed, it may not be verified, and we don't want to bring the user to the old chat if they
click on the bot email address. But trying to return accepted contacts increases security and
doesn't break the described scenario.
2025-08-06 17:37:17 -03:00
iequidoo
f5f4026dbb feat: Contact::lookup_id_by_addr_ex: Prefer returning key-contact
If an address-contact and a key-contact were seen at exactly the same time, that doesn't necessarily
mean that it's a random event, it might occur because some code updates contacts this way in some
scenario. While this is unlikely, prefer to look up the key-contact.
2025-08-06 17:37:17 -03:00
link2xt
b431206ede fix: allow receiving empty files 2025-08-06 13:33:04 +00:00
iequidoo
c4878e9b49 fix: Run wal_checkpoint during housekeeping (#6089)
Work around possible checkpoint starvations (there were cases reported when a WAL file is bigger
than 200M) and also make sure we truncate the WAL periodically. Auto-checkponting does not normally
truncate the WAL (unless the `journal_size_limit` pragma is set), see
https://www.sqlite.org/wal.html.
2025-08-05 14:58:32 -03:00
B. Petersen
aa452971a6 fix: ignore case when trying to detect 'invalid unencrypted mail' and add an info-message 2025-08-05 15:58:43 +02:00
dependabot[bot]
2d798f7cfe Merge pull request #7066 from chatmail/dependabot/cargo/toml-0.9.4 2025-08-04 23:53:05 +00:00
link2xt
08bb0484eb chore(release): prepare for 2.10.0 2025-08-04 22:33:59 +00:00
link2xt
b0b7337f5a chore: upgrade async-imap to 0.11.1 2025-08-04 21:42:28 +00:00
Hocuri
93241a4beb feat: Also lookup key contacts in lookup_id_by_addr() (#7073)
If there is both a key and an address contact, return the most recently
seen one.
2025-08-04 21:32:09 +02:00
iequidoo
4f1bf1f13c chore(deny.toml): add exception for duplicate toml_datetime 0.6.11 dependency 2025-08-02 16:16:43 -03:00
iequidoo
2d0b7b5bd8 chore(cargo): bump human-panic from 2.0.2 to 2.0.3 2025-08-02 16:14:48 -03:00
dependabot[bot]
8fe3ce5cab chore(cargo): bump strum_macros from 0.27.1 to 0.27.2
Bumps [strum_macros](https://github.com/Peternator7/strum) from 0.27.1 to 0.27.2.
- [Release notes](https://github.com/Peternator7/strum/releases)
- [Changelog](https://github.com/Peternator7/strum/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Peternator7/strum/compare/v0.27.1...v0.27.2)

---
updated-dependencies:
- dependency-name: strum_macros
  dependency-version: 0.27.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 14:15:55 -03:00
dependabot[bot]
59a0f1d94f chore(cargo): bump strum from 0.27.1 to 0.27.2
Bumps [strum](https://github.com/Peternator7/strum) from 0.27.1 to 0.27.2.
- [Release notes](https://github.com/Peternator7/strum/releases)
- [Changelog](https://github.com/Peternator7/strum/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Peternator7/strum/compare/v0.27.1...v0.27.2)

---
updated-dependencies:
- dependency-name: strum
  dependency-version: 0.27.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 13:55:09 -03:00
dependabot[bot]
5175dc3450 chore(cargo): bump criterion from 0.6.0 to 0.7.0
Bumps [criterion](https://github.com/bheisler/criterion.rs) from 0.6.0 to 0.7.0.
- [Changelog](https://github.com/bheisler/criterion.rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bheisler/criterion.rs/compare/0.6.0...0.7.0)

---
updated-dependencies:
- dependency-name: criterion
  dependency-version: 0.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 13:39:05 -03:00
dependabot[bot]
9a22ccd058 chore(cargo): bump hyper-util from 0.1.14 to 0.1.16
Bumps [hyper-util](https://github.com/hyperium/hyper-util) from 0.1.14 to 0.1.16.
- [Release notes](https://github.com/hyperium/hyper-util/releases)
- [Changelog](https://github.com/hyperium/hyper-util/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper-util/compare/v0.1.14...v0.1.16)

---
updated-dependencies:
- dependency-name: hyper-util
  dependency-version: 0.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 13:38:26 -03:00
dependabot[bot]
c06ed49a2a chore(cargo): bump async-channel from 2.3.1 to 2.5.0
Bumps [async-channel](https://github.com/smol-rs/async-channel) from 2.3.1 to 2.5.0.
- [Release notes](https://github.com/smol-rs/async-channel/releases)
- [Changelog](https://github.com/smol-rs/async-channel/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smol-rs/async-channel/compare/v2.3.1...v2.5.0)

---
updated-dependencies:
- dependency-name: async-channel
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 13:33:11 -03:00
dependabot[bot]
2e51a5a454 chore(cargo): bump bolero from 0.13.3 to 0.13.4
Bumps [bolero](https://github.com/camshaft/bolero) from 0.13.3 to 0.13.4.
- [Changelog](https://github.com/camshaft/bolero/blob/master/CHANGELOG.md)
- [Commits](https://github.com/camshaft/bolero/commits)

---
updated-dependencies:
- dependency-name: bolero
  dependency-version: 0.13.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 12:59:56 -03:00
dependabot[bot]
75cc353528 chore(cargo): bump serde_json from 1.0.140 to 1.0.142
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.140 to 1.0.142.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.140...v1.0.142)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.142
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-02 12:51:47 -03:00
dependabot[bot]
3977580426 chore(cargo): bump toml from 0.8.23 to 0.9.4
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.23 to 0.9.4.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.23...toml-v0.9.4)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-01 22:00:13 +00:00
link2xt
3a1370e174 chore(release): prepare for 2.9.0 2025-07-31 18:57:58 +00:00
iequidoo
c218c05b96 fix: get_chat_msgs_ex(): Report local midnight in ChatItem::DayMarker
We were reporting the UTC midnight timestamp instead. For UTC-N timezones that means reporting
"yesterday".

Fixes https://github.com/deltachat/deltachat-desktop/issues/5215.
2025-07-31 13:55:28 -03:00
iequidoo
db247d9f9a refactor: Don't call add_or_lookup_key_contacts() in advance
Its result isn't needed in all the branches.
2025-07-31 12:07:58 -03:00
iequidoo
78b7715ea6 refactor: Rename add_or_lookup_key_contacts_by_address_list() to add_or_lookup_key_contacts()
- It's obvious that addresses are needed to add contacts, so `_by_address_list` looks excessive.
- The function only looks up `SELF` by address.
- The name was confusing because there's also `lookup_key_contacts_by_address_list()` that actually
  looks up key contacts by addresses (not only `SELF`).
2025-07-31 12:07:58 -03:00
link2xt
ba76944d75 fix: display correct timer value for ephemeral timer changes
The timer change should not disappear,
but should display correct timer change.
2025-07-31 15:00:08 +00:00
cliffmccarthy
4a1a2122f0 feat(repl): Add import-vcard and make-vcard commands (#7048)
- Added import-vcard and make-vcard commands to deltachat-repl. These
  wrap the corresponding import_vcard() and make_vcard() operations from
  src/contact.rs. Each command takes a file path argument specifying the
  location of the vCard file to be read or written. The make-vcard command
  takes one or more IDs to write to the file.
- Documented commands in "Using the CLI client" section of README.md.

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2025-07-30 13:16:29 -03:00
link2xt
d80b749dec chore(release): prepare for 2.8.0 2025-07-28 19:31:43 +00:00
link2xt
039a8b7c36 fix: lookup self by address if there is no fingerprint or gossip 2025-07-28 19:18:07 +00:00
iequidoo
779f58ab16 feat: Remove ProtectionBroken, make such chats Unprotected (#7041)
Chats can't break anymore.
2025-07-28 16:01:14 -03:00
link2xt
b9183fe5eb chore(release): prepare for 2.7.0 2025-07-26 22:49:44 +00:00
Hocuri
9d342671d5 fix: Do not fail to upgrade if the verifier of a contact doesn't exist anymore (#7044)
Fix https://github.com/chatmail/core/issues/7043
2025-07-26 22:41:11 +02:00
link2xt
4e47ebd5fc test: fix flaky test_webxdc_resend 2025-07-24 17:47:19 +00:00
Hocuri
d5c418e909 feat: Put the debug/release build version into the info (#7034)
The info will look like:

```
debug_assertions=On - DO NOT RELEASE THIS BUILD
```
or:
```
debug_assertions=Off
```

I tested that this actually works when compiling on Android.

<details><summary>This is how it looked before the second
commit</summary>
<p>


The deltachat_core_version line in the info will look like:

```
deltachat_core_version=v2.5.0 [debug build]
```
or:
```
deltachat_core_version=v2.5.0 [release build]
```

I tested that this actually works when compiling on Android.



</p>
</details>
2025-07-24 17:24:54 +02:00
Hocuri
85414558c5 test: Add regression test for verification-gossiping crash (#7033)
This adds a test for https://github.com/chatmail/core/pull/7032/.

The crash happened if you received a message that has the from contact
also in the "To: " and "Chat-Group-Member-Fpr: " headers. Not sure how
it happened that such a message was created; I managed to create one in
a test, but needed to access some internals of MimeFactory for that.

I then saved the email that is sent by Alice into the test-data
directory, and then made a test that Bob doesn't crash if he receives
this email. And as a sanity-check, also test that Bob marks Fiona as
verified after receiving this email.
2025-07-24 17:24:00 +02:00
iequidoo
d6af8d2526 feat: mimefactory: Order message recipients by time of addition (#6872)
Sort recipients by `add_timestamp DESC` so that if the group is large and there are multiple SMTP
messages, a newly added member receives the member addition message earlier and has gossiped keys of
other members (otherwise the new member may receive messages from other members earlier and fail to
verify them).
2025-07-24 03:11:46 -03:00
Sebastian Klähn
1209e95e34 fix: realtime late join (#6869)
This PR adds a test to reproduce a bug raised by @adbenitez that peer
channels break when the resend feature is used.

---------

Co-authored-by: iequidoo <dgreshilov@gmail.com>
2025-07-23 12:50:53 +02:00
Hocuri
51f9279e67 chore(release): prepare for 2.6.0 2025-07-23 11:47:05 +02:00
Hocuri
f27d54f7fa fix: Fix crash when receiving a verification-gossiping message which a contact also sends to itself (#7032)
This should fix https://github.com/chatmail/core/issues/7030; @r10s if
you could test whether it fixes your problem

The crash happened if you received a message that has the from contact
also in the "To: " and "Chat-Group-Member-Fpr: " headers. Not sure how
it happened that such a message was created.
2025-07-23 11:09:11 +02:00
link2xt
7f3648f8ae chore(release): prepare for 2.5.0 2025-07-22 14:21:07 +00:00
link2xt
49fc258578 fix: do not ignore errors in add_flag_finalized_with_set 2025-07-22 14:17:30 +00:00
link2xt
0c51b4fe41 docs(STYLE.md): prefer try_next() over next() 2025-07-22 12:33:37 +00:00
bjoern
dbad714539 docs: clarify the meaning of is_verified() vs verifier_id() (#7027)
this PR adapts the documentation UI guidance to recent "green checkmark"
discussions

cmp https://github.com/deltachat/deltachat-pages/pull/1145,
https://github.com/deltachat/deltachat-ios/pull/2781,
https://github.com/deltachat/deltachat-android/pull/3828,
https://github.com/deltachat/deltachat-desktop/pull/5318

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-07-22 10:40:12 +02:00
Hocuri
edd8008650 fix: Mark all email chats as unprotected in the migration (#7026)
Previously, chat protection was only removed if the chat became an
email-chat because the key-contact has a different email, but not if the
chat became an email-chat for a different reason.

Therefore, it could happen that the migration produced a protected,
unencrypted chat, where you couldn't write any messages.

I tested that applying this fix actually fixes the bug I had.
2025-07-21 20:15:59 +00:00
Hocuri
615a1b3f4e fix: Correctly migrate "verified by me" 2025-07-21 18:28:19 +00:00
bjoern
fe6044e1aa docs: deprecate protection-broken and related stuff (#7018)
came over these parts while targeting the new info message of
https://github.com/chatmail/core/pull/7008 in
https://github.com/deltachat/deltachat-ios/pull/2778 and
https://github.com/deltachat/deltachat-android/pull/3822

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-07-21 18:40:00 +02:00
126 changed files with 6577 additions and 1718 deletions

View File

@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.88.0
RUST_VERSION: 1.89.0
# Minimum Supported Rust Version
MSRV: 1.85.0
@@ -30,7 +30,7 @@ jobs:
name: Lint Rust
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -53,7 +53,7 @@ jobs:
name: cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -67,7 +67,7 @@ jobs:
name: Check provider database
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -80,7 +80,7 @@ jobs:
env:
RUSTDOCFLAGS: -Dwarnings
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -115,7 +115,7 @@ jobs:
shell: bash
if: matrix.rust == 'latest'
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -154,7 +154,7 @@ jobs:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -179,7 +179,7 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -201,7 +201,7 @@ jobs:
name: Python lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -244,19 +244,19 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
- name: Download libdeltachat.a
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: ${{ matrix.os }}-libdeltachat.a
path: target/debug
- name: Install python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
@@ -297,13 +297,13 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
- name: Install python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
@@ -311,7 +311,7 @@ jobs:
run: pip install tox
- name: Download deltachat-rpc-server
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: ${{ matrix.os }}-deltachat-rpc-server
path: target/debug

View File

@@ -30,7 +30,7 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -54,7 +54,7 @@ jobs:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -79,7 +79,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -105,7 +105,7 @@ jobs:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -132,74 +132,74 @@ jobs:
contents: write
runs-on: "ubuntu-latest"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux armv7l binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv6l binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux i686 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d
- name: Download Linux x86_64 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d
- name: Download Win32 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d
- name: Download Win64 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d
- name: Download macOS binary for x86_64
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-x86_64-macos
path: deltachat-rpc-server-x86_64-macos.d
- name: Download macOS binary for aarch64
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-aarch64-macos
path: deltachat-rpc-server-aarch64-macos.d
- name: Download Android binary for arm64-v8a
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d
@@ -224,7 +224,7 @@ jobs:
# Python 3.11 is needed for tomllib used in scripts/wheel-rpc-server.py
- name: Install python 3.12
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: 3.12
@@ -285,76 +285,76 @@ jobs:
# Needed to publish the binaries to the release.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux armv7l binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv6l binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux i686 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d
- name: Download Linux x86_64 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d
- name: Download Win32 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d
- name: Download Win64 binary
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d
- name: Download macOS binary for x86_64
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-x86_64-macos
path: deltachat-rpc-server-x86_64-macos.d
- name: Download macOS binary for aarch64
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-aarch64-macos
path: deltachat-rpc-server-aarch64-macos.d
- name: Download Android binary for arm64-v8a
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d
@@ -401,7 +401,7 @@ jobs:
deltachat-rpc-server/npm-package/*.tgz
# Configure Node.js for publishing.
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 20
registry-url: "https://registry.npmjs.org"

View File

@@ -14,12 +14,12 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 20
registry-url: "https://registry.npmjs.org"

View File

@@ -16,12 +16,12 @@ jobs:
build_and_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
- name: Use Node.js 18.x
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 18.x
- name: Add Rust cache

View File

@@ -19,7 +19,7 @@ jobs:
name: check flake formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -81,7 +81,7 @@ jobs:
#- deltachat-rpc-server-x86_64-android
#- deltachat-rpc-server-x86-android
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -101,7 +101,7 @@ jobs:
# - deltachat-rpc-server-aarch64-darwin
# - deltachat-rpc-server-x86_64-darwin
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/

View File

@@ -14,7 +14,7 @@ jobs:
name: Build REPL example
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -50,7 +50,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
@@ -72,13 +72,13 @@ jobs:
working-directory: ./deltachat-jsonrpc/typescript
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- name: Use Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '18'
- name: npm install

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
show-progress: false
persist-credentials: false

View File

@@ -14,7 +14,7 @@ jobs:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
persist-credentials: false

View File

@@ -1,5 +1,211 @@
# Changelog
## [2.13.0] - 2025-09-09
### API-Changes
- [**breaking**] Remove `is_profile_verified` APIs.
- [**breaking**] Remove deprecated `is_protection_broken`.
- [**breaking**] Remove `e2ee_enabled` preference.
### Features / Changes
- Add call ringing API ([#6650](https://github.com/chatmail/core/pull/6650), [#7174](https://github.com/chatmail/core/pull/7174), [#7175](https://github.com/chatmail/core/pull/7175), [#7179](https://github.com/chatmail/core/pull/7179))
- Warn for outdated versions after 6 months instead of 1 year ([#7144](https://github.com/chatmail/core/pull/7144)).
- Do not set "unknown sender for this chat" error.
- Do not replace messages with an error on verification failure.
- Support receiving Autocrypt-Gossip with `_verified` attribute.
- Withdraw all QR codes when one is withdrawn.
### Fixes
- Don't reverify contacts by SELF on receipt of a message from another device.
- Don't verify contacts by others having an unknown verifier.
- Update verifier_id if it's "unknown" and new verifier has known verifier.
- Mark message as failed if it can't be sent ([#7143](https://github.com/chatmail/core/pull/7143)).
- Add "Messages are end-to-end encrypted." to non-protected groups.
### Documentation
- Fix for SecurejoinInviterProgress with progress == 600.
- STYLE.md: Prefer BTreeMap and BTreeSet over hash variants.
### Miscellaneous Tasks
- Update provider database.
- Update dependencies.
### Refactor
- Check that verifier is verified in turn.
- Remove unused `EncryptPreference::Reset`.
- Remove `Aheader::new`.
### Tests
- Add another TimeShiftFalsePositiveNote ([#7142](https://github.com/chatmail/core/pull/7142)).
- Add TestContext.create_chat_id.
## [2.12.0] - 2025-08-26
### API-Changes
- api!(python): remove remaining broken API for reactions
### Features / Changes
- Use Group ID for chat color generation instead of the name for encrypted groups.
- Use key fingerprints instead of addresses for key-contacts color generation.
- Replace HSLuv colors with OKLCh.
- `wal_checkpoint()`: Do `wal_checkpoint(PASSIVE)` and `wal_checkpoint(FULL)` before `wal_checkpoint(TRUNCATE)`.
- Assign messages to key-contacts based on Issuer Fingerprint.
- Create_group_ex(): Log and replace invalid chat name with "…".
### Fixes
- Do not create a group if the sender includes self in the `To` field.
- Do not reverify already verified contacts via gossip.
- `get_connectivity()`: Get rid of locking SchedulerState::inner ([#7124](https://github.com/chatmail/core/pull/7124)).
- Make reaction message hidden only if there are no other parts.
### Refactor
- Do not return `Result` from `valid_signature_fingerprints()`.
- Make `ConnectivityStore` use a non-async lock ([#7129](https://github.com/chatmail/core/pull/7129)).
### Documentation
- Remove broken link from documentation comments.
- Remove the comment about Color Vision Deficiency correction.
## [2.11.0] - 2025-08-13
### Features / Changes
- Contact::lookup_id_by_addr_ex: Prefer returning key-contact.
- Contact::lookup_id_by_addr_ex: Prefer returning accepted contacts.
- Better string when using disappearing messages of one year (365..367 days, so it can be tweaked later).
- Do not require resent messages to be from the same chat.
- `lookup_key_contact_by_address()`: Allow looking up ContactId::SELF without chat id.
- `get_securejoin_qr()`: Log error if group doesn't have grpid.
- `receive_imf::add_parts()`: Get rid of extra `Chat::load_from_db()` calls.
### Fixes
- Ignore case when trying to detect 'invalid unencrypted mail' and add an info-message.
- Run wal_checkpoint during housekeeping ([#6089](https://github.com/chatmail/core/pull/6089)).
- Allow receiving empty files.
- Set correct sent_timestamp for saved outgoing messages.
- Do not remove query parameters from URLs.
- Log and set imex progress error ([#7091](https://github.com/chatmail/core/pull/7091)).
- Do not add key-contacts to unencrypted groups.
- Do not reset `GuaranteeE2ee` in the database when resending messages.
- Assign messages to a group if there is a `Chat-Group-Name`.
- Take `Chat-Group-Name` into account when matching ad hoc groups.
- Don't break long group names with non-ASCII characters.
- Add messages that can't be verified as `DownloadState::Available` ([#7059](https://github.com/chatmail/core/pull/7059)).
### Tests
- Log the number of the test account if there are multiple alices ([#7087](https://github.com/chatmail/core/pull/7087)).
### CI
- Update Rust to 1.89.0.
### Refactor
- Rename icon-address-contact to icon-unencrypted.
- Skip loading the contact of 1:1 unencrypted chat to show the avatar.
- Chat::is_encrypted(): Make one query instead of two for 1:1 chats.
### Miscellaneous Tasks
- cargo: Bump toml from 0.8.23 to 0.9.4.
- cargo: Bump human-panic from 2.0.2 to 2.0.3.
- deny.toml: Add exception for duplicate toml_datetime 0.6.11 dependency.
- deps: Bump actions/checkout from 4 to 5.
- deps: Bump actions/download-artifact from 4 to 5.
## [2.10.0] - 2025-08-04
### Features / Changes
- Also lookup key contacts in lookup_id_by_addr() ([#7073](https://github.com/chatmail/core/pull/7073)).
### Miscellaneous Tasks
- cargo: Bump serde_json from 1.0.140 to 1.0.142.
- cargo: Bump bolero from 0.13.3 to 0.13.4.
- cargo: Bump async-channel from 2.3.1 to 2.5.0.
- cargo: Bump hyper-util from 0.1.14 to 0.1.16.
- cargo: Bump criterion from 0.6.0 to 0.7.0.
- cargo: Bump strum from 0.27.1 to 0.27.2.
- cargo: Bump strum_macros from 0.27.1 to 0.27.2.
- Upgrade async-imap to 0.11.1.
## [2.9.0] - 2025-07-31
### Features / Changes
- repl: Add import-vcard and make-vcard commands ([#7048](https://github.com/chatmail/core/pull/7048)).
### Fixes
- Display correct timer value for ephemeral timer changes.
- Get_chat_msgs_ex(): Report local midnight in ChatItem::DayMarker.
### Refactor
- Rename add_or_lookup_key_contacts_by_address_list() to add_or_lookup_key_contacts().
- Don't call add_or_lookup_key_contacts() in advance.
## [2.8.0] - 2025-07-28
### Features / Changes
- Remove ProtectionBroken, make such chats Unprotected ([#7041](https://github.com/chatmail/core/pull/7041)).
### Fixes
- Lookup self by address if there is no fingerprint or gossip.
## [2.7.0] - 2025-07-26
### Features / Changes
- Mimefactory: Order message recipients by time of addition ([#6872](https://github.com/chatmail/core/pull/6872)).
- Put the debug/release build version into the info ([#7034](https://github.com/chatmail/core/pull/7034)).
### Fixes
- Realtime late join ([#6869](https://github.com/chatmail/core/pull/6869)).
- Do not fail to upgrade if the verifier of a contact doesn't exist anymore ([#7044](https://github.com/chatmail/core/pull/7044)).
### Tests
- Add regression test for verification-gossiping crash ([#7033](https://github.com/chatmail/core/pull/7033)).
## [2.6.0] - 2025-07-23
### Fixes
- Fix crash when receiving a verification-gossiping message which a contact also sends to itself ([#7032](https://github.com/chatmail/core/pull/7032)).
## [2.5.0] - 2025-07-22
### Fixes
- Correctly migrate "verified by me".
- Mark all email chats as unprotected in the migration ([#7026](https://github.com/chatmail/core/pull/7026)).
- Do not ignore errors in add_flag_finalized_with_set.
### Documentation
- Deprecate protection-broken and related stuff ([#7018](https://github.com/chatmail/core/pull/7018)).
- Clarify the meaning of is_verified() vs verifier_id() ([#7027](https://github.com/chatmail/core/pull/7027)).
- STYLE.md: Prefer `try_next()` over `next()`.
## [2.4.0] - 2025-07-21
### Fixes
@@ -6511,3 +6717,11 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.2.0]: https://github.com/chatmail/core/compare/v2.1.0..v2.2.0
[2.3.0]: https://github.com/chatmail/core/compare/v2.2.0..v2.3.0
[2.4.0]: https://github.com/chatmail/core/compare/v2.3.0..v2.4.0
[2.5.0]: https://github.com/chatmail/core/compare/v2.4.0..v2.5.0
[2.6.0]: https://github.com/chatmail/core/compare/v2.5.0..v2.6.0
[2.7.0]: https://github.com/chatmail/core/compare/v2.6.0..v2.7.0
[2.8.0]: https://github.com/chatmail/core/compare/v2.7.0..v2.8.0
[2.9.0]: https://github.com/chatmail/core/compare/v2.8.0..v2.9.0
[2.10.0]: https://github.com/chatmail/core/compare/v2.9.0..v2.10.0
[2.11.0]: https://github.com/chatmail/core/compare/v2.10.0..v2.11.0
[2.12.0]: https://github.com/chatmail/core/compare/v2.11.0..v2.12.0

421
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.4.0"
version = "2.13.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.85"
@@ -44,14 +44,16 @@ ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.0", default-features = false, features = ["runtime-tokio", "compress"] }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.17", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
blake3 = "1.8.2"
brotli = { version = "8", default-features=false, features = ["std"] }
bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.7.5", default-features = false }
data-encoding = "2.9.0"
escaper = "0.1"
fast-socks5 = "0.10"
@@ -63,13 +65,13 @@ hickory-resolver = "0.25.2"
http-body-util = "0.1.3"
humansize = "2"
hyper = "1"
hyper-util = "0.1.14"
hyper-util = "0.1.16"
image = { version = "0.25.6", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh-gossip = { version = "0.35", default-features = false, features = ["net"] }
iroh = { version = "0.35", default-features = false }
kamadak-exif = "0.6.1"
libc = { workspace = true }
mail-builder = { version = "0.4.3", default-features = false }
mail-builder = { version = "0.4.4", default-features = false }
mailparse = { workspace = true }
mime = "0.3.17"
num_cpus = "1.17"
@@ -85,7 +87,6 @@ quoted_printable = "0.5"
rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
rustls-pki-types = "1.12.0"
rustls = { version = "0.23.22", default-features = false }
sanitize-filename = { workspace = true }
@@ -107,16 +108,15 @@ tokio-stream = { version = "0.1.17", features = ["fs"] }
tokio-tar = { version = "0.3" } # TODO: integrate tokio into async-tar
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.8"
toml = "0.9"
tracing = "0.1.41"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
webpki-roots = "0.26.8"
blake3 = "1.8.2"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
criterion = { version = "0.6.0", features = ["async_tokio"] }
criterion = { version = "0.7.0", features = ["async_tokio"] }
futures-lite = { workspace = true }
log = { workspace = true }
nu-ansi-term = { workspace = true }
@@ -157,6 +157,11 @@ name = "receive_emails"
required-features = ["internals"]
harness = false
[[bench]]
name = "benchmark_decrypting"
required-features = ["internals"]
harness = false
[[bench]]
name = "get_chat_msgs"
harness = false
@@ -175,18 +180,18 @@ harness = false
[workspace.dependencies]
anyhow = "1"
async-channel = "2.3.1"
async-channel = "2.5.0"
base64 = "0.22"
chrono = { version = "0.4.41", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat = { path = ".", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.0"
futures-lite = "2.6.1"
libc = "0.2"
log = "0.4"
mailparse = "0.16.1"
nu-ansi-term = "0.46"
nu-ansi-term = "0.50"
num-traits = "0.2"
rand = "0.8"
regex = "1.10"
@@ -194,10 +199,10 @@ rusqlite = "0.36"
sanitize-filename = "0.5"
serde = "1.0"
serde_json = "1"
tempfile = "3.20.0"
tempfile = "3.21.0"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.14"
tokio-util = "0.7.16"
tracing-subscriber = "0.3"
yerpc = "0.6.4"

View File

@@ -80,18 +80,26 @@ Connect to your mail server (if already configured):
> connect
```
Create a contact:
Export your public key to a vCard file:
```
> make-vcard my.vcard 1
```
Create contacts by address or vCard file:
```
> addcontact yourfriends@email.org
> import-vcard key-contact.vcard
```
List contacts:
```
> listcontacts
Contact#Contact#11: key-contact@email.org <key-contact@email.org>
Contact#Contact#Self: Me √ <your@email.org>
1 key contacts.
2 key contacts.
Contact#Contact#10: yourfriends@email.org <yourfriends@email.org>
1 address contacts.
```

View File

@@ -78,6 +78,27 @@ All errors should be handled in one of these ways:
- With `.log_err().ok()`.
- Bubbled up with `?`.
When working with [async streams](https://docs.rs/futures/0.3.31/futures/stream/index.html),
prefer [`try_next`](https://docs.rs/futures/0.3.31/futures/stream/trait.TryStreamExt.html#method.try_next) over `next()`, e.g. do
```
while let Some(event) = stream.try_next().await? {
todo!();
}
```
instead of
```
while let Some(event_res) = stream.next().await {
todo!();
}
```
as it allows bubbling up the error early with `?`
with no way to accidentally skip error processing
with early `continue` or `break`.
Some streams reading from a connection
return infinite number of `Some(Err(_))`
items when connection breaks and not processing
errors may result in infinite loop.
`backtrace` feature is enabled for `anyhow` crate
and `debug = 1` option is set in the test profile.
This allows to run `RUST_BACKTRACE=1 cargo test`
@@ -91,6 +112,18 @@ Follow
<https://doc.rust-lang.org/core/error/index.html#common-message-styles>
for `.expect` message style.
## BTreeMap vs HashMap
Prefer [BTreeMap](https://doc.rust-lang.org/std/collections/struct.BTreeMap.html)
over [HashMap](https://doc.rust-lang.org/std/collections/struct.HashMap.html)
and [BTreeSet](https://doc.rust-lang.org/std/collections/struct.BTreeSet.html)
over [HashSet](https://doc.rust-lang.org/std/collections/struct.HashSet.html)
as iterating over these structures returns items in deterministic order.
Non-deterministic code may result in difficult to reproduce bugs,
flaky tests, regression tests that miss bugs
or different behavior on different devices when processing the same messages.
## Logging
For logging, use `info!`, `warn!` and `error!` macros.

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,199 @@
//! Benchmarks for message decryption,
//! comparing decryption of symmetrically-encrypted messages
//! to decryption of asymmetrically-encrypted messages.
//!
//! Call with
//!
//! ```text
//! cargo bench --bench benchmark_decrypting --features="internals"
//! ```
//!
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
//!
//! ```text
//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
//! ```
//!
//! You can also pass a substring.
//! So, you can run all 'Decrypt and parse' benchmarks with:
//!
//! ```text
//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt and parse'
//! ```
//!
//! Symmetric decryption has to try out all known secrets,
//! You can benchmark this by adapting the `NUM_SECRETS` variable.
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use deltachat::internals_for_benchmarks::create_broadcast_shared_secret;
use deltachat::internals_for_benchmarks::create_dummy_keypair;
use deltachat::internals_for_benchmarks::save_broadcast_shared_secret;
use deltachat::{
Events,
chat::ChatId,
config::Config,
context::Context,
internals_for_benchmarks::key_from_asc,
internals_for_benchmarks::parse_and_get_text,
internals_for_benchmarks::store_self_keypair,
pgp::{KeyPair, decrypt, pk_encrypt, symm_encrypt_message},
stock_str::StockStrings,
};
use rand::{Rng, thread_rng};
use tempfile::tempdir;
const NUM_SECRETS: usize = 500;
async fn create_context() -> Context {
let dir = tempdir().unwrap();
let dbfile = dir.path().join("db.sqlite");
let context = Context::new(dbfile.as_path(), 100, Events::new(), StockStrings::new())
.await
.unwrap();
context
.set_config(Config::ConfiguredAddr, Some("bob@example.net"))
.await
.unwrap();
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
let public = secret.signed_public_key();
let key_pair = KeyPair { public, secret };
store_self_keypair(&context, &key_pair)
.await
.expect("Failed to save key");
context
}
fn criterion_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("Decrypt");
// ===========================================================================================
// Benchmarks for decryption only, without any other parsing
// ===========================================================================================
group.sample_size(10);
group.bench_function("Decrypt a symmetrically encrypted message", |b| {
let plain = generate_plaintext();
let secrets = generate_secrets();
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
let secret = secrets[NUM_SECRETS / 2].clone();
symm_encrypt_message(
plain.clone(),
black_box(&secret),
create_dummy_keypair("alice@example.org").unwrap().secret,
true,
)
.await
.unwrap()
});
b.iter(|| {
let mut msg =
decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap();
let decrypted = msg.as_data_vec().unwrap();
assert_eq!(black_box(decrypted), plain);
});
});
group.bench_function("Decrypt a public-key encrypted message", |b| {
let plain = generate_plaintext();
let key_pair = create_dummy_keypair("alice@example.org").unwrap();
let secrets = generate_secrets();
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
pk_encrypt(
plain.clone(),
vec![black_box(key_pair.public.clone())],
Some(key_pair.secret.clone()),
true,
)
.await
.unwrap()
});
b.iter(|| {
let mut msg = decrypt(
encrypted.clone().into_bytes(),
std::slice::from_ref(&key_pair.secret),
black_box(&secrets),
)
.unwrap();
let decrypted = msg.as_data_vec().unwrap();
assert_eq!(black_box(decrypted), plain);
});
});
// ===========================================================================================
// Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf())
// ===========================================================================================
let rt = tokio::runtime::Runtime::new().unwrap();
let mut secrets = generate_secrets();
// "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml.
// Put it into the middle of our secrets:
secrets[NUM_SECRETS / 2] = "secret".to_string();
let context = rt.block_on(async {
let context = create_context().await;
for (i, secret) in secrets.iter().enumerate() {
save_broadcast_shared_secret(&context, ChatId::new(10 + i as u32), secret)
.await
.unwrap();
}
context
});
group.bench_function("Decrypt and parse a symmetrically encrypted message", |b| {
b.to_async(&rt).iter(|| {
let ctx = context.clone();
async move {
let text = parse_and_get_text(
&ctx,
include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"),
)
.await
.unwrap();
assert_eq!(text, "Symmetrically encrypted message");
}
});
});
group.bench_function("Decrypt and parse a public-key encrypted message", |b| {
b.to_async(&rt).iter(|| {
let ctx = context.clone();
async move {
let text = parse_and_get_text(
&ctx,
include_bytes!("../test-data/message/text_from_alice_encrypted.eml"),
)
.await
.unwrap();
assert_eq!(text, "hi");
}
});
});
group.finish();
}
fn generate_secrets() -> Vec<String> {
let secrets: Vec<String> = (0..NUM_SECRETS)
.map(|_| create_broadcast_shared_secret())
.collect();
secrets
}
fn generate_plaintext() -> Vec<u8> {
let mut plain: Vec<u8> = vec![0; 500];
thread_rng().fill(&mut plain[..]);
plain
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

View File

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

View File

@@ -415,7 +415,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* As for `displayname` and `selfstatus`, also the avatar is sent to the recipients.
* To save traffic, however, the avatar is attached only as needed
* and also recoded to a reasonable size.
* - `e2ee_enabled` = 0=no end-to-end-encryption, 1=prefer end-to-end-encryption (default)
* - `mdns_enabled` = 0=do not send or request read receipts,
* 1=send and request read receipts
* default=send and request read receipts, only send but not request if `bot` is set
@@ -503,13 +502,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* - `gossip_period` = How often to gossip Autocrypt keys in chats with multiple recipients, in
* seconds. 2 days by default.
* This is not supposed to be changed by UIs and only used for testing.
* - `verified_one_on_one_chats` = Feature flag for verified 1:1 chats; the UI should set it
* to 1 if it supports verified 1:1 chats.
* Regardless of this setting, `dc_chat_is_protected()` returns true while the key is verified,
* and when the key changes, an info message is posted into the chat.
* 0=Nothing else happens when the key changes.
* 1=After the key changed, `dc_chat_can_send()` returns false and `dc_chat_is_protection_broken()` returns true
* until `dc_accept_chat()` is called.
* - `is_chatmail` = 1 if the the server is a chatmail server, 0 otherwise.
* - `is_muted` = Whether a context is muted by the user.
* Muted contexts should not sound, vibrate or show notifications.
@@ -1222,6 +1214,103 @@ void dc_set_webxdc_integration (dc_context_t* context, const char* f
uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t chat_id);
/**
* Start an outgoing call.
* This sends a message of type #DC_MSG_CALL with all relevant information to the callee,
* who will get informed by an #DC_EVENT_INCOMING_CALL event and rings.
*
* Possible actions during ringing:
*
* - caller cancels the call using dc_end_call():
* callee receives #DC_EVENT_CALL_ENDED and has a "Missed Call"
*
* - callee accepts using dc_accept_incoming_call():
* caller receives #DC_EVENT_OUTGOING_CALL_ACCEPTED.
* callee's devices receive #DC_EVENT_INCOMING_CALL_ACCEPTED, call starts
*
* - callee declines using dc_end_call():
* caller receives #DC_EVENT_CALL_ENDED and has a "Declinced Call".
* callee's other devices receive #DC_EVENT_CALL_ENDED and have a "Cancelled Call",
*
* - callee is already in a call:
* in this case, UI may decide to show a notification instead of ringing.
* otherwise, this is same as timeout
*
* - timeout:
* after 1 minute without action,
* caller and callee receive #DC_EVENT_CALL_ENDED
* to prevent endless ringing of callee
* in case caller got offline without being able to send cancellation message.
* for caller, this is a "Cancelled Call";
* for callee, this is a "Missed Call"
*
* Actions during the call:
*
* - caller ends the call using dc_end_call():
* callee receives #DC_EVENT_CALL_ENDED
*
* - callee ends the call using dc_end_call():
* caller receives #DC_EVENT_CALL_ENDED
*
* Note, that the events are for updating the call screen,
* possible status messages are added and updated as usual, including the known events.
* In the UI, the sorted chatlist is used as an overview about calls as well as messages.
* To place a call with a contact that has no chat yet, use dc_create_chat_by_contact_id() first.
*
* UI will usually allow only one call at the same time,
* this has to be tracked by UI across profile, the core does not track this.
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id The chat to place a call for.
* This needs to be a one-to-one chat.
* @param place_call_info any data that other devices receive
* in #DC_EVENT_INCOMING_CALL.
* @return ID of the system message announcing the call.
*/
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info);
/**
* Accept incoming call.
*
* This implicitly accepts the contact request, if not yet done.
* All affected devices will receive
* either #DC_EVENT_OUTGOING_CALL_ACCEPTED or #DC_EVENT_INCOMING_CALL_ACCEPTED.
*
* If the call is already accepted or ended, nothing happens.
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id The ID of the call to accept.
* This is the ID reported by #DC_EVENT_INCOMING_CALL
* and equals to the ID of the corresponding info message.
* @param accept_call_info any data that other devices receive
* in #DC_EVENT_OUTGOING_CALL_ACCEPTED.
* @return 1=success, 0=error
*/
int dc_accept_incoming_call (dc_context_t* context, uint32_t msg_id, const char* accept_call_info);
/**
* End incoming or outgoing call.
*
* For unaccepted calls ended by the caller, this is a "cancellation".
* Unaccepted calls ended by the callee are a "decline".
* If the call was accepted, this is a "hangup".
*
* All participant devices get informed about the ended call via #DC_EVENT_CALL_ENDED.
*
* If the call is already ended, nothing happens.
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id the ID of the call.
* @return 1=success, 0=error
*/
int dc_end_call (dc_context_t* context, uint32_t msg_id);
/**
* Save a draft for a chat in the database.
*
@@ -1339,12 +1428,14 @@ dc_msg_t* dc_get_draft (dc_context_t* context, uint32_t ch
* Optionally, some special markers added to the ID array may help to
* implement virtual lists.
*
* To get the concrete time of the message, use dc_array_get_timestamp().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
* @param chat_id The chat ID of which the messages IDs should be queried.
* @param flags If set to DC_GCM_ADDDAYMARKER, the marker DC_MSG_ID_DAYMARKER will
* be added before each day (regarding the local timezone). Set this to 0 if you do not want this behaviour.
* To get the concrete time of the marker, use dc_array_get_timestamp().
* The day marker timestamp is the midnight one for the corresponding (following) day in the local timezone.
* If set to DC_GCM_INFO_ONLY, only system messages will be returned, can be combined with DC_GCM_ADDDAYMARKER.
* @param marker1before Deprecated, set this to 0.
* @return Array of message IDs, must be dc_array_unref()'d when no longer used.
@@ -2094,9 +2185,19 @@ int dc_may_be_valid_addr (const char* addr);
/**
* Check if an e-mail address belongs to a known and unblocked contact.
* Looks up a known and unblocked contact with a given e-mail address.
* To get a list of all known and unblocked contacts, use dc_get_contacts().
*
* **POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
* (e.g. an address-contact and a key-contact),
* this looks up the most recently seen contact,
* i.e. which contact is returned depends on which contact last sent a message.
* If the user just clicked on a mailto: link, then this is the best thing you can do.
* But **DO NOT** internally represent contacts by their email address
* and do not use this function to look them up;
* otherwise this function will sometimes look up the wrong contact.
* Instead, you should internally represent contacts by their ids.
*
* To validate an e-mail address independently of the contact database
* use dc_may_be_valid_addr().
*
@@ -2118,6 +2219,13 @@ uint32_t dc_lookup_contact_id_by_addr (dc_context_t* context, const char*
* To add a number of contacts, see dc_add_address_book() which is much faster for adding
* a bunch of addresses.
*
* This will always create or look up an address-contact,
* i.e. a contact identified by an email address,
* with all messages sent to and from this contact being unencrypted.
* If the user just clicked on an email address,
* you should first check `lookup_contact_id_by_addr`,
* and only if there is no contact yet, call this function here.
*
* May result in a #DC_EVENT_CONTACTS_CHANGED event.
*
* @memberof dc_context_t
@@ -3818,21 +3926,12 @@ int dc_chat_can_send (const dc_chat_t* chat);
/**
* Check if a chat is protected.
*
* End-to-end encryption is guaranteed in protected chats
* and only verified contacts
* Only verified contacts
* as determined by dc_contact_is_verified()
* can be added to protected chats.
*
* Protected chats are created using dc_create_group_chat()
* by setting the 'protect' parameter to 1.
* 1:1 chats become protected or unprotected automatically
* if `verified_one_on_one_chats` setting is enabled.
*
* UI should display a green checkmark
* in the chat title,
* in the chatlist item
* and in the chat profile
* if chat protection is enabled.
*
* @memberof dc_chat_t
* @param chat The chat object.
@@ -3856,26 +3955,6 @@ int dc_chat_is_protected (const dc_chat_t* chat);
int dc_chat_is_encrypted (const dc_chat_t *chat);
/**
* Checks if the chat was protected, and then an incoming message broke this protection.
*
* This function is only useful if the UI enabled the `verified_one_on_one_chats` feature flag,
* otherwise it will return false for all chats.
*
* 1:1 chats are automatically set as protected when a contact is verified.
* When a message comes in that is not encrypted / signed correctly,
* the chat is automatically set as unprotected again.
* dc_chat_is_protection_broken() will return true until dc_accept_chat() is called.
*
* The UI should let the user confirm that this is OK with a message like
* `Bob sent a message from another device. Tap to learn more` and then call dc_accept_chat().
* @memberof dc_chat_t
* @param chat The chat object.
* @return 1=chat protection broken, 0=otherwise.
*/
int dc_chat_is_protection_broken (const dc_chat_t* chat);
/**
* Check if locations are sent to the chat
* at the time the object was created using dc_get_chat().
@@ -5267,20 +5346,14 @@ int dc_contact_is_blocked (const dc_contact_t* contact);
/**
* Check if the contact
* can be added to verified chats,
* i.e. has a verified key
* and Autocrypt key matches the verified key.
* can be added to protected chats.
*
* If contact is verified
* UI should display green checkmark after the contact name
* in contact list items,
* in chat member list items
* and in profiles if no chat with the contact exist (otherwise, use dc_chat_is_protected()).
* See dc_contact_get_verifier_id() for a guidance how to display these information.
*
* @memberof dc_contact_t
* @param contact The contact object.
* @return 0: contact is not verified.
* 2: SELF and contact have verified their fingerprints in both directions; in the UI typically checkmarks are shown.
* 2: SELF and contact have verified their fingerprints in both directions.
*/
int dc_contact_is_verified (dc_contact_t* contact);
@@ -5311,16 +5384,22 @@ int dc_contact_is_key_contact (dc_contact_t* contact);
/**
* Return the contact ID that verified a contact.
*
* If the function returns non-zero result,
* display green checkmark in the profile and "Introduced by ..." line
* with the name and address of the contact
* formatted by dc_contact_get_name_n_addr.
* As verifier may be unknown,
* use dc_contact_is_verified() to check if a contact can be added to a protected chat.
*
* If this function returns a verifier,
* this does not necessarily mean
* you can add the contact to verified chats.
* Use dc_contact_is_verified() to check
* if a contact can be added to a verified chat instead.
* UI should display the information in the contact's profile as follows:
*
* - If dc_contact_get_verifier_id() != 0,
* display text "Introduced by ..."
* with the name and address of the contact
* formatted by dc_contact_get_name_n_addr().
* Prefix the text by a green checkmark.
*
* - If dc_contact_get_verifier_id() == 0 and dc_contact_is_verified() != 0,
* display "Introduced" prefixed by a green checkmark.
*
* - if dc_contact_get_verifier_id() == 0 and dc_contact_is_verified() == 0,
* display nothing
*
* @memberof dc_contact_t
* @param contact The contact object.
@@ -5633,6 +5712,12 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
#define DC_MSG_VIDEOCHAT_INVITATION 70
/**
* Message indicating an incoming or outgoing call.
*/
#define DC_MSG_CALL 71
/**
* The message is a webxdc instance.
*
@@ -6386,7 +6471,6 @@ void dc_event_unref(dc_event_t* event);
/**
* Chat changed. The name or the image of a chat group was changed or members were added or removed.
* Or the verify state of a chat has changed.
* See dc_set_chat_name(), dc_set_chat_profile_image(), dc_add_contact_to_chat()
* and dc_remove_contact_from_chat().
*
@@ -6478,7 +6562,7 @@ void dc_event_unref(dc_event_t* event);
* @param data1 (int) The ID of the contact that wants to join.
* @param data2 (int) The progress as:
* 300=vg-/vc-request received, typically shown as "bob@addr joins".
* 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
* 600=vg-/vc-request-with-auth received and verified, typically shown as "bob@addr verified".
* 800=contact added to chat, shown as "bob@addr securely joined GROUP". Only for the verified-group-protocol.
* 1000=Protocol finished for this contact.
*/
@@ -6632,6 +6716,61 @@ void dc_event_unref(dc_event_t* event);
*/
#define DC_EVENT_CHANNEL_OVERFLOW 2400
/**
* Incoming call.
* UI will usually start ringing,
* or show a notification if there is already a call in some profile.
*
* Together with this event,
* a message of type #DC_MSG_CALL is added to the corresponding chat;
* this message is announced and updated by the usual even as #DC_EVENT_MSGS_CHANGED.
*
* If user takes action, dc_accept_incoming_call() or dc_end_call() should be called.
*
* Otherwise, ringing should end on #DC_EVENT_CALL_ENDED
* or #DC_EVENT_INCOMING_CALL_ACCEPTED
*
* @param data1 (int) msg_id ID of the message referring to the call.
* @param data2 (char*) place_call_info, text passed to dc_place_outgoing_call()
*/
#define DC_EVENT_INCOMING_CALL 2550
/**
* The callee accepted an incoming call on this or another device using dc_accept_incoming_call().
* The caller gets the event #DC_EVENT_OUTGOING_CALL_ACCEPTED at the same time.
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take 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
*/
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
/**
* A call placed using dc_place_outgoing_call() was accepted by the callee using dc_accept_incoming_call().
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take 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 (char*) accept_call_info, text passed to dc_accept_incoming_call()
*/
#define DC_EVENT_OUTGOING_CALL_ACCEPTED 2570
/**
* An incoming or outgoing call was ended using dc_end_call().
* Moreover, the event is sent when the call was not accepted within 1 minute timeout.
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take 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
*/
#define DC_EVENT_CALL_ENDED 2580
/**
* @}
*/
@@ -7052,6 +7191,8 @@ void dc_event_unref(dc_event_t* event);
/// "Unknown sender for this chat. See 'info' for more details."
///
/// Use as message text if assigning the message to a chat is not totally correct.
///
/// @deprecated 2025-08-18
#define DC_STR_UNKNOWN_SENDER_FOR_CHAT 72
/// "Message from %1$s"
@@ -7594,6 +7735,18 @@ void dc_event_unref(dc_event_t* event);
/// `%2$s` will be replaced by name and address of the contact.
#define DC_STR_EPHEMERAL_TIMER_WEEKS_BY_OTHER 157
/// "You set message deletion timer to 1 year."
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_1_YEAR_BY_YOU 158
/// "Message deletion timer is set to 1 year by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_1_YEAR_BY_OTHER 159
/// "Scan to set up second device for %1$s"
///
/// `%1$s` will be replaced by name and address of the account.

View File

@@ -375,7 +375,7 @@ pub unsafe extern "C" fn dc_get_connectivity(context: *const dc_context_t) -> li
return 0;
}
let ctx = &*context;
block_on(ctx.get_connectivity()) as u32 as libc::c_int
ctx.get_connectivity() as u32 as libc::c_int
}
#[no_mangle]
@@ -556,6 +556,10 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::AccountsChanged => 2302,
EventType::AccountsItemChanged => 2303,
EventType::EventChannelOverflow { .. } => 2400,
EventType::IncomingCall { .. } => 2550,
EventType::IncomingCallAccepted { .. } => 2560,
EventType::OutgoingCallAccepted { .. } => 2570,
EventType::CallEnded { .. } => 2580,
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
@@ -619,7 +623,11 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
EventType::WebxdcRealtimeData { msg_id, .. }
| EventType::WebxdcStatusUpdate { msg_id, .. }
| EventType::WebxdcRealtimeAdvertisementReceived { msg_id }
| EventType::WebxdcInstanceDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int,
| EventType::WebxdcInstanceDeleted { msg_id, .. }
| EventType::IncomingCall { msg_id, .. }
| EventType::IncomingCallAccepted { msg_id, .. }
| EventType::OutgoingCallAccepted { msg_id, .. }
| EventType::CallEnded { msg_id, .. } => msg_id.to_u32() as libc::c_int,
EventType::ChatlistItemChanged { chat_id } => {
chat_id.unwrap_or_default().to_u32() as libc::c_int
}
@@ -671,6 +679,10 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ChatModified(_)
| EventType::ChatDeleted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::IncomingCall { .. }
| EventType::IncomingCallAccepted { .. }
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. } => 0,
EventType::MsgsChanged { msg_id, .. }
| EventType::ReactionsChanged { msg_id, .. }
@@ -767,8 +779,21 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::ChatlistChanged
| EventType::AccountsChanged
| EventType::AccountsItemChanged
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::EventChannelOverflow { .. } => ptr::null_mut(),
| EventType::IncomingCallAccepted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. } => ptr::null_mut(),
EventType::IncomingCall {
place_call_info, ..
} => {
let data2 = place_call_info.to_c_string().unwrap_or_default();
data2.into_raw()
}
EventType::OutgoingCallAccepted {
accept_call_info, ..
} => {
let data2 = accept_call_info.to_c_string().unwrap_or_default();
data2.into_raw()
}
EventType::CallEnded { .. } | EventType::EventChannelOverflow { .. } => ptr::null_mut(),
EventType::ConfigureProgress { comment, .. } => {
if let Some(comment) = comment {
comment.to_c_string().unwrap_or_default().into_raw()
@@ -1167,6 +1192,61 @@ pub unsafe extern "C" fn dc_init_webxdc_integration(
.unwrap_or(0)
}
#[no_mangle]
pub unsafe extern "C" fn dc_place_outgoing_call(
context: *mut dc_context_t,
chat_id: u32,
place_call_info: *const libc::c_char,
) -> u32 {
if context.is_null() || chat_id == 0 {
eprintln!("ignoring careless call to dc_place_outgoing_call()");
return 0;
}
let ctx = &*context;
let chat_id = ChatId::new(chat_id);
let place_call_info = to_string_lossy(place_call_info);
block_on(ctx.place_outgoing_call(chat_id, place_call_info))
.context("Failed to place call")
.log_err(ctx)
.map(|msg_id| msg_id.to_u32())
.unwrap_or_log_default(ctx, "Failed to place call")
}
#[no_mangle]
pub unsafe extern "C" fn dc_accept_incoming_call(
context: *mut dc_context_t,
msg_id: u32,
accept_call_info: *const libc::c_char,
) -> libc::c_int {
if context.is_null() || msg_id == 0 {
eprintln!("ignoring careless call to dc_accept_incoming_call()");
return 0;
}
let ctx = &*context;
let msg_id = MsgId::new(msg_id);
let accept_call_info = to_string_lossy(accept_call_info);
block_on(ctx.accept_incoming_call(msg_id, accept_call_info))
.context("Failed to accept call")
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_end_call(context: *mut dc_context_t, msg_id: u32) -> libc::c_int {
if context.is_null() || msg_id == 0 {
eprintln!("ignoring careless call to dc_end_call()");
return 0;
}
let ctx = &*context;
let msg_id = MsgId::new(msg_id);
block_on(ctx.end_call(msg_id))
.context("Failed to end call")
.log_err(ctx)
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_set_draft(
context: *mut dc_context_t,
@@ -3165,16 +3245,6 @@ pub unsafe extern "C" fn dc_chat_is_encrypted(chat: *mut dc_chat_t) -> libc::c_i
.unwrap_or_log_default(&ffi_chat.context, "Failed dc_chat_is_encrypted") as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_chat_is_protection_broken(chat: *mut dc_chat_t) -> libc::c_int {
if chat.is_null() {
eprintln!("ignoring careless call to dc_chat_is_protection_broken()");
return 0;
}
let ffi_chat = &*chat;
ffi_chat.chat.is_protection_broken() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_chat_is_sending_locations(chat: *mut dc_chat_t) -> libc::c_int {
if chat.is_null() {

View File

@@ -45,6 +45,7 @@ impl Lot {
Self::Qr(qr) => match qr {
Qr::AskVerifyContact { .. } => None,
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::AskJoinBroadcast { broadcast_name, .. } => Some(Cow::Borrowed(broadcast_name)),
Qr::FprOk { .. } => None,
Qr::FprMismatch { .. } => None,
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
@@ -99,6 +100,7 @@ impl Lot {
Self::Qr(qr) => match qr {
Qr::AskVerifyContact { .. } => LotState::QrAskVerifyContact,
Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup,
Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast,
Qr::FprOk { .. } => LotState::QrFprOk,
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
@@ -126,6 +128,7 @@ impl Lot {
Self::Qr(qr) => match qr {
Qr::AskVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::AskVerifyGroup { .. } => Default::default(),
Qr::AskJoinBroadcast { .. } => Default::default(),
Qr::FprOk { contact_id } => contact_id.to_u32(),
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
Qr::FprWithoutAddr { .. } => Default::default(),
@@ -169,6 +172,9 @@ pub enum LotState {
/// text1=groupname
QrAskVerifyGroup = 202,
/// text1=broadcast_name
QrAskJoinBroadcast = 204,
/// id=contact
QrFprOk = 210,

View File

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

View File

@@ -999,7 +999,7 @@ impl CommandApi {
.await
}
/// Create a new **broadcast channel**
/// Create a new, outgoing **broadcast channel**
/// (called "Channel" in the UI).
///
/// Broadcast channels are similar to groups on the sending device,
@@ -1227,8 +1227,10 @@ impl CommandApi {
}
/// Returns all messages of a particular chat.
/// If `add_daymarker` is `true`, it will return them as
/// `DC_MSG_ID_DAYMARKER`, e.g. [1234, 1237, 9, 1239].
///
/// * `add_daymarker` - If `true`, add day markers as `DC_MSG_ID_DAYMARKER` to the result,
/// e.g. [1234, 1237, 9, 1239]. The day marker timestamp is the midnight one for the
/// corresponding (following) day in the local timezone.
async fn get_message_ids(
&self,
account_id: u32,
@@ -1471,7 +1473,14 @@ impl CommandApi {
/// Add a single contact as a result of an explicit user action.
///
/// Returns contact id of the created or existing contact
/// This will always create or look up an address-contact,
/// i.e. a contact identified by an email address,
/// with all messages sent to and from this contact being unencrypted.
/// If the user just clicked on an email address,
/// you should first check [`Self::lookup_contact_id_by_addr`]/`lookupContactIdByAddr.`,
/// and only if there is no contact yet, call this function here.
///
/// Returns contact id of the created or existing contact.
async fn create_contact(
&self,
account_id: u32,
@@ -1621,9 +1630,19 @@ impl CommandApi {
Contact::get_encrinfo(&ctx, ContactId::new(contact_id)).await
}
/// Check if an e-mail address belongs to a known and unblocked contact.
/// Looks up a known and unblocked contact with a given e-mail address.
/// To get a list of all known and unblocked contacts, use contacts_get_contacts().
///
/// **POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
/// (e.g. an address-contact and a key-contact),
/// this looks up the most recently seen contact,
/// i.e. which contact is returned depends on which contact last sent a message.
/// If the user just clicked on a mailto: link, then this is the best thing you can do.
/// But **DO NOT** internally represent contacts by their email address
/// and do not use this function to look them up;
/// otherwise this function will sometimes look up the wrong contact.
/// Instead, you should internally represent contacts by their ids.
///
/// To validate an e-mail address independently of the contact database
/// use check_email_validity().
async fn lookup_contact_id_by_addr(
@@ -1889,7 +1908,7 @@ impl CommandApi {
/// If the connectivity changes, a #DC_EVENT_CONNECTIVITY_CHANGED will be emitted.
async fn get_connectivity(&self, account_id: u32) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.get_connectivity().await as u32)
Ok(ctx.get_connectivity() as u32)
}
/// Get an overview of the current connectivity, and possibly more statistics.

View File

@@ -21,15 +21,16 @@ pub struct FullChat {
/// True if the chat is protected.
///
/// UI should display a green checkmark
/// in the chat title,
/// in the chat profile title and
/// in the chatlist item
/// if chat protection is enabled.
/// UI should also display a green checkmark
/// in the contact profile
/// if 1:1 chat with this contact exists and is protected.
/// Only verified contacts
/// as determined by [`ContactObject::is_verified`] / `Contact.isVerified`
/// can be added to protected chats.
///
/// Protected chats are created using [`create_group_chat`] / `createGroupChat()`
/// by setting the 'protect' parameter to true.
///
/// [`create_group_chat`]: crate::api::CommandApi::create_group_chat
is_protected: bool,
/// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
@@ -70,7 +71,7 @@ pub struct FullChat {
fresh_message_counter: usize,
// is_group - please check over chat.type in frontend instead
is_contact_request: bool,
is_protection_broken: bool,
is_device_chat: bool,
self_in_group: bool,
is_muted: bool,
@@ -144,7 +145,6 @@ impl FullChat {
color,
fresh_message_counter,
is_contact_request: chat.is_contact_request(),
is_protection_broken: chat.is_protection_broken(),
is_device_chat: chat.is_device_talk(),
self_in_group: contact_ids.contains(&ContactId::SELF),
is_muted: chat.is_muted(),
@@ -215,7 +215,7 @@ pub struct BasicChat {
is_self_talk: bool,
color: String,
is_contact_request: bool,
is_protection_broken: bool,
is_device_chat: bool,
is_muted: bool,
}
@@ -244,7 +244,6 @@ impl BasicChat {
is_self_talk: chat.is_self_talk(),
color,
is_contact_request: chat.is_contact_request(),
is_protection_broken: chat.is_protection_broken(),
is_device_chat: chat.is_device_talk(),
is_muted: chat.is_muted(),
})

View File

@@ -31,27 +31,36 @@ pub struct ContactObject {
/// e.g. if we just scanned the fingerprint from a QR code.
e2ee_avail: bool,
/// True if the contact can be added to verified groups.
/// True if the contact
/// can be added to protected chats
/// because SELF and contact have verified their fingerprints in both directions.
///
/// If this is true
/// UI should display green checkmark after the contact name
/// in contact list items,
/// in chat member list items
/// and in profiles if no chat with the contact exist.
/// See [`Self::verifier_id`]/`Contact.verifierId` for a guidance how to display these information.
is_verified: bool,
/// True if the contact profile title should have a green checkmark.
/// The contact ID that verified a contact.
///
/// This indicates whether 1:1 chat has a green checkmark
/// or will have a green checkmark if created.
is_profile_verified: bool,
/// The ID of the contact that verified this contact.
/// As verifier may be unknown,
/// use [`Self::is_verified`]/`Contact.isVerified` to check if a contact can be added to a protected chat.
///
/// If this is present,
/// display a green checkmark and "Introduced by ..."
/// string followed by the verifier contact name and address
/// in the contact profile.
/// UI should display the information in the contact's profile as follows:
///
/// - If `verifierId` != 0,
/// display text "Introduced by ..."
/// with the name and address of the contact
/// formatted by `name_and_addr`/`nameAndAddr`.
/// Prefix the text by a green checkmark.
///
/// - If `verifierId` == 0 and `isVerified` != 0,
/// display "Introduced" prefixed by a green checkmark.
///
/// - if `verifierId` == 0 and `isVerified` == 0,
/// display nothing
///
/// This contains the contact ID of the verifier.
/// If it is `DC_CONTACT_ID_SELF`, we verified the contact ourself.
/// If it is None/Null, we don't have verifier information or
/// the contact is not verified.
verifier_id: Option<u32>,
/// the contact's last seen timestamp
@@ -72,7 +81,6 @@ impl ContactObject {
None => None,
};
let is_verified = contact.is_verified(context).await?;
let is_profile_verified = contact.is_profile_verified(context).await?;
let verifier_id = contact
.get_verifier_id(context)
@@ -94,7 +102,6 @@ impl ContactObject {
is_key_contact: contact.is_key_contact(),
e2ee_avail: contact.e2ee_avail(context).await?,
is_verified,
is_profile_verified,
verifier_id,
last_seen: contact.last_seen(),
was_seen_recently: contact.was_seen_recently(),

View File

@@ -1,4 +1,5 @@
use deltachat::{Event as CoreEvent, EventType as CoreEventType};
use num_traits::ToPrimitive;
use serde::Serialize;
use typescript_type_def::TypeDef;
@@ -224,7 +225,6 @@ pub enum EventType {
},
/// Chat changed. The name or the image of a chat group was changed or members were added or removed.
/// Or the verify state of a chat has changed.
/// See setChatName(), setChatProfileImage(), addContactToChat()
/// and removeContactFromChat().
///
@@ -304,9 +304,14 @@ pub enum EventType {
/// ID of the contact that wants to join.
contact_id: u32,
/// The type of the joined chat.
/// This can take the same values
/// as `BasicChat.chatType` ([`crate::api::types::chat::BasicChat::chat_type`]).
chat_type: u32,
/// Progress as:
/// 300=vg-/vc-request received, typically shown as "bob@addr joins".
/// 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
/// 600=vg-/vc-request-with-auth received and verified, typically shown as "bob@addr verified".
/// 800=contact added to chat, shown as "bob@addr securely joined GROUP". Only for the verified-group-protocol.
/// 1000=Protocol finished for this contact.
progress: usize,
@@ -417,6 +422,35 @@ pub enum EventType {
/// Number of events skipped.
n: u64,
},
/// Incoming call.
IncomingCall {
/// ID of the info message referring to the call.
msg_id: u32,
/// User-defined info as passed to place_outgoing_call()
place_call_info: String,
},
/// Incoming call accepted.
/// This is esp. interesting to stop ringing on other devices.
IncomingCallAccepted {
/// ID of the info message referring to the call.
msg_id: u32,
},
/// Outgoing call accepted.
OutgoingCallAccepted {
/// ID of the info message referring to the call.
msg_id: u32,
/// User-defined info passed to dc_accept_incoming_call(
accept_call_info: String,
},
/// Call ended.
CallEnded {
/// ID of the info message referring to the call.
msg_id: u32,
},
}
impl From<CoreEventType> for EventType {
@@ -523,9 +557,11 @@ impl From<CoreEventType> for EventType {
},
CoreEventType::SecurejoinInviterProgress {
contact_id,
chat_type,
progress,
} => SecurejoinInviterProgress {
contact_id: contact_id.to_u32(),
chat_type: chat_type.to_u32().unwrap_or(0),
progress,
},
CoreEventType::SecurejoinJoinerProgress {
@@ -567,6 +603,26 @@ impl From<CoreEventType> for EventType {
CoreEventType::EventChannelOverflow { n } => EventChannelOverflow { n },
CoreEventType::AccountsChanged => AccountsChanged,
CoreEventType::AccountsItemChanged => AccountsItemChanged,
CoreEventType::IncomingCall {
msg_id,
place_call_info,
} => IncomingCall {
msg_id: msg_id.to_u32(),
place_call_info,
},
CoreEventType::IncomingCallAccepted { msg_id } => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
},
CoreEventType::OutgoingCallAccepted {
msg_id,
accept_call_info,
} => OutgoingCallAccepted {
msg_id: msg_id.to_u32(),
accept_call_info,
},
CoreEventType::CallEnded { msg_id } => CallEnded {
msg_id: msg_id.to_u32(),
},
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),

View File

@@ -324,6 +324,9 @@ pub enum MessageViewtype {
/// Message is an invitation to a videochat.
VideochatInvitation,
/// Message is a call.
Call,
/// Message is an webxdc instance.
Webxdc,
@@ -346,6 +349,7 @@ impl From<Viewtype> for MessageViewtype {
Viewtype::Video => MessageViewtype::Video,
Viewtype::File => MessageViewtype::File,
Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation,
Viewtype::Call => MessageViewtype::Call,
Viewtype::Webxdc => MessageViewtype::Webxdc,
Viewtype::Vcard => MessageViewtype::Vcard,
}
@@ -365,6 +369,7 @@ impl From<MessageViewtype> for Viewtype {
MessageViewtype::Video => Viewtype::Video,
MessageViewtype::File => Viewtype::File,
MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation,
MessageViewtype::Call => Viewtype::Call,
MessageViewtype::Webxdc => Viewtype::Webxdc,
MessageViewtype::Vcard => Viewtype::Vcard,
}
@@ -437,6 +442,9 @@ pub enum SystemMessageType {
/// This message contains a users iroh node address.
IrohNodeAddr,
CallAccepted,
CallEnded,
}
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -463,6 +471,8 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::IrohNodeAddr => SystemMessageType::IrohNodeAddr,
SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait,
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
SystemMessage::CallAccepted => SystemMessageType::CallAccepted,
SystemMessage::CallEnded => SystemMessageType::CallEnded,
}
}
}

View File

@@ -34,6 +34,23 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user whether to join the broadcast channel.
AskJoinBroadcast {
/// Chat name.
broadcast_name: String,
/// A string of random characters,
/// uniquely identifying this broadcast channel in the database.
/// Called `grpid` for historic reasons:
/// The id of multi-user chats is always called `grpid` in the database
/// because groups were once the only multi-user chats.
grpid: String,
/// ID of the contact who owns the channel and created the QR code.
contact_id: u32,
/// Fingerprint of the contact's key as scanned from the QR code.
fingerprint: String,
authcode: String,
},
/// Contact fingerprint is verified.
///
/// Ask the user if they want to start chatting.
@@ -207,6 +224,23 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::AskJoinBroadcast {
broadcast_name,
grpid,
contact_id,
fingerprint,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::AskJoinBroadcast {
broadcast_name,
grpid,
contact_id,
fingerprint,
authcode,
}
}
Qr::FprOk { contact_id } => {
let contact_id = contact_id.to_u32();
QrObject::FprOk { contact_id }

View File

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

View File

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

View File

@@ -403,6 +403,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
block <contact-id>\n\
unblock <contact-id>\n\
listblocked\n\
import-vcard <file>\n\
make-vcard <file> <contact-id> [contact-id ...]\n\
======================================Misc.==\n\
getqr [<chat-id>]\n\
getqrsvg [<chat-id>]\n\
@@ -1218,6 +1220,24 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
log_contactlist(&context, &contacts).await?;
println!("{} blocked contacts.", contacts.len());
}
"import-vcard" => {
ensure!(!arg1.is_empty(), "Argument <file> missing.");
let vcard_content = fs::read_to_string(&arg1.to_string()).await?;
let contacts = import_vcard(&context, &vcard_content).await?;
println!("vCard contacts imported:");
log_contactlist(&context, &contacts).await?;
}
"make-vcard" => {
ensure!(!arg1.is_empty(), "Argument <file> missing.");
ensure!(!arg2.is_empty(), "Argument <contact-id> missing.");
let mut contact_ids = vec![];
for x in arg2.split_whitespace() {
contact_ids.push(ContactId::new(x.parse()?))
}
let vcard_content = make_vcard(&context, &contact_ids).await?;
fs::write(&arg1.to_string(), vcard_content).await?;
println!("vCard written to: {arg1}");
}
"checkqr" => {
ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
let qr = check_qr(&context, arg1).await?;

View File

@@ -232,7 +232,7 @@ const MESSAGE_COMMANDS: [&str; 10] = [
"delmsg",
"react",
];
const CONTACT_COMMANDS: [&str; 7] = [
const CONTACT_COMMANDS: [&str; 9] = [
"listcontacts",
"addcontact",
"contactinfo",
@@ -240,6 +240,8 @@ const CONTACT_COMMANDS: [&str; 7] = [
"block",
"unblock",
"listblocked",
"import-vcard",
"make-vcard",
];
const MISC_COMMANDS: [&str; 14] = [
"getqr",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.4.0"
version = "2.13.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -185,7 +185,21 @@ class Account:
return Contact(self, contact_id)
def get_contact_by_addr(self, address: str) -> Optional[Contact]:
"""Check if an e-mail address belongs to a known and unblocked contact."""
"""Looks up a known and unblocked contact with a given e-mail address.
To get a list of all known and unblocked contacts, use contacts_get_contacts().
**POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
(e.g. an address-contact and a key-contact),
this looks up the most recently seen contact,
i.e. which contact is returned depends on which contact last sent a message.
If the user just clicked on a mailto: link, then this is the best thing you can do.
But **DO NOT** internally represent contacts by their email address
and do not use this function to look them up;
otherwise this function will sometimes look up the wrong contact.
Instead, you should internally represent contacts by their ids.
To validate an e-mail address independently of the contact database
use check_email_validity()."""
contact_id = self._rpc.lookup_contact_id_by_addr(self.id, address)
return contact_id and Contact(self, contact_id)
@@ -310,7 +324,7 @@ class Account:
return Chat(self, self._rpc.create_group_chat(self.id, name, protect))
def create_broadcast(self, name: str) -> Chat:
"""Create a new **broadcast channel**
"""Create a new, outgoing **broadcast channel**
(called "Channel" in the UI).
Broadcast channels are similar to groups on the sending device,

View File

@@ -93,6 +93,17 @@ class Message:
if event.kind == EventType.MSG_DELIVERED and event.msg_id == self.id:
break
def resend(self) -> None:
"""Resend messages and make information available for newly added chat members.
Resending sends out the original message, however, recipients and webxdc-status may differ.
Clients that already have the original message can still ignore the resent message as
they have tracked the state by dedicated updates.
Some messages cannot be resent, eg. info-messages, drafts, already pending messages,
or messages that are not sent by SELF.
"""
self._rpc.resend_messages(self.account.id, [self.id])
@futuremethod
def send_webxdc_realtime_advertisement(self):
"""Send an advertisement to join the realtime channel."""

View File

@@ -3,6 +3,7 @@ import logging
import pytest
from deltachat_rpc_client import Chat, EventType, SpecialContactId
from deltachat_rpc_client.const import ChatType
from deltachat_rpc_client.rpc import JsonRpcError
@@ -112,6 +113,132 @@ def test_qr_securejoin(acfactory, protect):
fiona.wait_for_securejoin_joiner_success()
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_qr_securejoin_broadcast(acfactory, all_devices_online):
alice, bob, fiona = acfactory.get_online_accounts(3)
alice2 = alice.clone()
bob2 = bob.clone()
if all_devices_online:
alice2.start_io()
bob2.start_io()
logging.info("===================== Alice creates a broadcast =====================")
alice_chat = alice.create_broadcast("Broadcast channel for everyone!")
snapshot = alice_chat.get_basic_snapshot()
assert not snapshot.is_unpromoted # Broadcast channels are never unpromoted
logging.info("===================== Bob joins the broadcast =====================")
qr_code = alice_chat.get_qr_code()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
bob.wait_for_securejoin_joiner_success()
alice_chat.send_text("Hello everyone!")
def wait_for_group_messages(ac):
snapshot = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == f"Member Me added by {alice.get_config('addr')}."
snapshot = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello everyone!"
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
# Check that the chat partner is verified.
contact_snapshot = contact.get_snapshot()
assert contact_snapshot.is_verified
chat = ac.get_chatlist()[0]
chat_msgs = chat.get_messages()
if please_wait_info_msg:
first_msg = chat_msgs.pop(0).get_snapshot()
assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…"
assert first_msg.is_info
encrypted_msg = chat_msgs[0].get_snapshot()
assert encrypted_msg.text == "Messages are end-to-end encrypted."
assert encrypted_msg.is_info
member_added_msg = chat_msgs[1].get_snapshot()
if inviter_side:
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
else:
assert member_added_msg.text == f"Member Me added by {contact_snapshot.display_name}."
assert member_added_msg.is_info
hello_msg = chat_msgs[2].get_snapshot()
assert hello_msg.text == "Hello everyone!"
assert not hello_msg.is_info
assert hello_msg.show_padlock
assert hello_msg.error is None
assert len(chat_msgs) == 3
chat_snapshot = chat.get_full_snapshot()
assert chat_snapshot.is_encrypted
assert chat_snapshot.name == "Broadcast channel for everyone!"
if inviter_side:
assert chat_snapshot.chat_type == ChatType.OUT_BROADCAST
else:
assert chat_snapshot.chat_type == ChatType.IN_BROADCAST
assert chat_snapshot.can_send == inviter_side
chat_contacts = chat_snapshot.contact_ids
assert contact.id in chat_contacts
if inviter_side:
assert len(chat_contacts) == 1
else:
assert len(chat_contacts) == 2
assert SpecialContactId.SELF in chat_contacts
assert chat_snapshot.self_in_group
wait_for_group_messages(bob)
check_account(alice, alice.create_contact(bob), inviter_side=True)
check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
logging.info("===================== Test Alice's second device =====================")
# Start second Alice device, if it wasn't started already.
alice2.start_io()
alice2.wait_for_securejoin_inviter_success()
while True:
msg_id = alice2.wait_for_msgs_changed_event().msg_id
if msg_id:
snapshot = alice2.get_message_by_id(msg_id).get_snapshot()
if snapshot.text == "Hello everyone!":
break
check_account(alice2, alice2.create_contact(bob), inviter_side=True)
logging.info("===================== Test Bob's second device =====================")
# Start second Bob device, if it wasn't started already.
bob2.start_io()
bob2.wait_for_securejoin_joiner_success()
wait_for_group_messages(bob2)
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
# The QR code token is synced, so alice2 must be able to handle join requests.
logging.info("===================== Fiona joins the group via alice2 =====================")
alice.stop_io()
fiona.secure_join(qr_code)
alice2.wait_for_securejoin_inviter_success()
fiona.wait_for_securejoin_joiner_success()
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == f"Member Me added by {alice.get_config('addr')}."
alice2.get_chatlist()[0].get_messages()[2].resend()
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello everyone!"
check_account(fiona, fiona.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
def test_qr_securejoin_contact_request(acfactory) -> None:
"""Alice invites Bob to a group when Bob's chat with Alice is in a contact request mode."""
alice, bob = acfactory.get_online_accounts(2)

View File

@@ -11,7 +11,7 @@ from unittest.mock import MagicMock
import pytest
from deltachat_rpc_client import Contact, EventType, Message, events
from deltachat_rpc_client.const import ChatType, DownloadState, MessageState
from deltachat_rpc_client.const import DownloadState, MessageState
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
from deltachat_rpc_client.rpc import JsonRpcError
@@ -171,7 +171,10 @@ def test_account(acfactory) -> None:
assert alice.get_size()
assert alice.is_configured()
assert not alice.get_avatar()
assert alice.get_contact_by_addr(bob_addr) is None # There is no address-contact, only key-contact
# get_contact_by_addr() can lookup a key contact by address:
bob_contact = alice.get_contact_by_addr(bob_addr).get_snapshot()
assert bob_contact.display_name == "Bob"
assert bob_contact.is_key_contact
assert alice.get_contacts()
assert alice.get_contacts(snapshot=True)
assert alice.self_contact
@@ -873,34 +876,103 @@ def test_delete_deltachat_folder(acfactory, direct_imap):
assert "DeltaChat" in ac1_direct_imap.list_folders()
def test_broadcast(acfactory):
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_leave_broadcast(acfactory, all_devices_online):
alice, bob = acfactory.get_online_accounts(2)
alice_chat = alice.create_broadcast("My great channel")
snapshot = alice_chat.get_basic_snapshot()
assert snapshot.name == "My great channel"
assert snapshot.is_unpromoted
assert snapshot.is_encrypted
assert snapshot.chat_type == ChatType.OUT_BROADCAST
bob2 = bob.clone()
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat.add_contact(alice_contact_bob)
if all_devices_online:
bob2.start_io()
alice_msg = alice_chat.send_message(text="hello").get_snapshot()
assert alice_msg.text == "hello"
assert alice_msg.show_padlock
logging.info("===================== Alice creates a broadcast =====================")
alice_chat = alice.create_broadcast("Broadcast channel for everyone!")
bob_msg = bob.wait_for_incoming_msg().get_snapshot()
assert bob_msg.text == "hello"
assert bob_msg.show_padlock
assert bob_msg.error is None
logging.info("===================== Bob joins the broadcast =====================")
qr_code = alice_chat.get_qr_code()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
bob.wait_for_securejoin_joiner_success()
bob_chat = bob.get_chat_by_id(bob_msg.chat_id)
bob_chat_snapshot = bob_chat.get_basic_snapshot()
assert bob_chat_snapshot.name == "My great channel"
assert not bob_chat_snapshot.is_unpromoted
assert bob_chat_snapshot.is_encrypted
assert bob_chat_snapshot.chat_type == ChatType.IN_BROADCAST
assert bob_chat_snapshot.is_contact_request
alice_bob_contact = alice.create_contact(bob)
alice_contacts = alice_chat.get_contacts()
assert len(alice_contacts) == 1 # 1 recipient
assert alice_contacts[0].id == alice_bob_contact.id
assert not bob_chat.can_send()
member_added_msg = bob.wait_for_incoming_msg()
assert member_added_msg.get_snapshot().text == f"Member Me added by {alice.get_config('addr')}."
def get_broadcast(ac):
chat = ac.get_chatlist()[0]
assert chat.get_basic_snapshot().name == "Broadcast channel for everyone!"
return chat
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
chat = get_broadcast(ac)
contact_snapshot = contact.get_snapshot()
chat_msgs = chat.get_messages()
if please_wait_info_msg:
first_msg = chat_msgs.pop(0).get_snapshot()
assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…"
assert first_msg.is_info
encrypted_msg = chat_msgs.pop(0).get_snapshot()
assert encrypted_msg.text == "Messages are end-to-end encrypted."
assert encrypted_msg.is_info
member_added_msg = chat_msgs.pop(0).get_snapshot()
if inviter_side:
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
else:
assert member_added_msg.text == f"Member Me added by {contact_snapshot.display_name}."
assert member_added_msg.is_info
if not inviter_side:
leave_msg = chat_msgs.pop(0).get_snapshot()
assert leave_msg.text == "You left."
assert len(chat_msgs) == 0
chat_snapshot = chat.get_full_snapshot()
# On Alice's side, SELF is not in the list of contact ids
# because OutBroadcast chats never contain SELF in the list.
# On Bob's side, SELF is not in the list because he left.
if inviter_side:
assert len(chat_snapshot.contact_ids) == 0
else:
assert chat_snapshot.contact_ids == [contact.id]
logging.info("===================== Bob leaves the broadcast =====================")
bob_chat = get_broadcast(bob)
assert bob_chat.get_full_snapshot().self_in_group
assert len(bob_chat.get_contacts()) == 2 # Alice and Bob
bob_chat.leave()
assert not bob_chat.get_full_snapshot().self_in_group
# After Bob left, only Alice will be left in Bob's memberlist
assert len(bob_chat.get_contacts()) == 1
check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
logging.info("===================== Test Alice's device =====================")
while len(alice_chat.get_contacts()) != 0: # After Bob left, there will be 0 recipients
alice.wait_for_event(EventType.CHAT_MODIFIED)
check_account(alice, alice.create_contact(bob), inviter_side=True)
logging.info("===================== Test Bob's second device =====================")
# Start second Bob device, if it wasn't started already.
bob2.start_io()
member_added_msg = bob2.wait_for_incoming_msg()
assert member_added_msg.get_snapshot().text == f"Member Me added by {alice.get_config('addr')}."
bob2_chat = get_broadcast(bob2)
# After Bob left, only Alice will be left in Bob's memberlist
while len(bob2_chat.get_contacts()) != 1:
bob2.wait_for_event(EventType.CHAT_MODIFIED)
check_account(bob2, bob2.create_contact(alice), inviter_side=False)

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.4.0"
version = "2.13.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.4.0"
"version": "2.13.0"
}

View File

@@ -38,8 +38,6 @@ skip = [
{ name = "rand", version = "0.8.5" },
{ name = "redox_syscall", version = "0.3.5" },
{ name = "redox_syscall", version = "0.4.1" },
{ name = "regex-automata", version = "0.1.10" },
{ name = "regex-syntax", version = "0.6.29" },
{ name = "rustix", version = "0.38.44" },
{ name = "serdect", version = "0.2.0" },
{ name = "spin", version = "0.9.8" },
@@ -48,6 +46,7 @@ skip = [
{ name = "syn", version = "1.0.109" },
{ name = "thiserror-impl", version = "1.0.69" },
{ name = "thiserror", version = "1.0.69" },
{ name = "toml_datetime", version = "0.6.11" },
{ name = "wasi", version = "0.11.0+wasi-snapshot-preview1" },
{ name = "windows" },
{ name = "windows_aarch64_gnullvm" },

View File

@@ -6,7 +6,7 @@ edition = "2021"
license = "MPL-2.0"
[dev-dependencies]
bolero = "0.13.3"
bolero = "0.13.4"
[dependencies]
mailparse = { workspace = true }

View File

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

View File

@@ -330,7 +330,21 @@ class Account:
return bool(lib.dc_delete_contact(self._dc_context, contact_id))
def get_contact_by_addr(self, email: str) -> Optional[Contact]:
"""get a contact for the email address or None if it's blocked or doesn't exist."""
"""Looks up a known and unblocked contact with a given e-mail address.
To get a list of all known and unblocked contacts, use contacts_get_contacts().
**POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
(e.g. an address-contact and a key-contact),
this looks up the most recently seen contact,
i.e. which contact is returned depends on which contact last sent a message.
If the user just clicked on a mailto: link, then this is the best thing you can do.
But **DO NOT** internally represent contacts by their email address
and do not use this function to look them up;
otherwise this function will sometimes look up the wrong contact.
Instead, you should internally represent contacts by their ids.
To validate an e-mail address independently of the contact database
use check_email_validity()."""
_, addr = parseaddr(email)
addr = as_dc_charpointer(addr)
contact_id = lib.dc_lookup_contact_id_by_addr(self._dc_context, addr)

View File

@@ -8,7 +8,6 @@ from typing import Optional, Union
from . import const, props
from .capi import ffi, lib
from .cutil import as_dc_charpointer, from_dc_charpointer, from_optional_dc_charpointer
from .reactions import Reactions
class Message:
@@ -164,17 +163,6 @@ class Message:
),
)
def send_reaction(self, reaction: str):
"""Send a reaction to message and return the resulting Message instance."""
msg_id = lib.dc_send_reaction(self.account._dc_context, self.id, as_dc_charpointer(reaction))
if msg_id == 0:
raise ValueError("reaction could not be send")
return Message.from_db(self.account, msg_id)
def get_reactions(self) -> Reactions:
"""Get :class:`deltachat.reactions.Reactions` to the message."""
return Reactions.from_msg(self)
def is_system_message(self):
"""return True if this message is a system/info message."""
return bool(lib.dc_msg_is_info(self._dc_msg))

View File

@@ -1,43 +0,0 @@
"""The Reactions object."""
from .capi import ffi, lib
from .cutil import from_dc_charpointer, iter_array
class Reactions:
"""Reactions object.
You obtain instances of it through :class:`deltachat.message.Message`.
"""
def __init__(self, account, dc_reactions) -> None:
assert isinstance(account._dc_context, ffi.CData)
assert isinstance(dc_reactions, ffi.CData)
assert dc_reactions != ffi.NULL
self.account = account
self._dc_reactions = dc_reactions
def __repr__(self) -> str:
return f"<Reactions dc_reactions={self._dc_reactions}>"
@classmethod
def from_msg(cls, msg):
assert msg.id > 0
return cls(
msg.account,
ffi.gc(lib.dc_get_msg_reactions(msg.account._dc_context, msg.id), lib.dc_reactions_unref),
)
def get_contacts(self) -> list:
"""Get list of contacts reacted to the message.
:returns: list of :class:`deltachat.contact.Contact` objects for this reaction.
"""
from .contact import Contact
dc_array = ffi.gc(lib.dc_reactions_get_contacts(self._dc_reactions), lib.dc_array_unref)
return list(iter_array(dc_array, lambda x: Contact(self.account, x)))
def get_by_contact(self, contact) -> str:
"""Get a string containing space-separated reactions of a single :class:`deltachat.contact.Contact`."""
return from_dc_charpointer(lib.dc_reactions_get_by_contact_id(self._dc_reactions, contact.id))

View File

@@ -160,32 +160,6 @@ def test_html_message(acfactory, lp):
assert html_text in msg2.html
def test_videochat_invitation_message(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = acfactory.get_accepted_chat(ac1, ac2)
text = "You are invited to a video chat, click https://meet.jit.si/WxEGad0gGzX to join."
lp.sec("ac1: prepare and send text message to ac2")
msg1 = chat.send_text("message0")
assert not msg1.is_videochat_invitation()
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0"
assert not msg2.is_videochat_invitation()
lp.sec("ac1: prepare and send videochat invitation to ac2")
msg1 = Message.new_empty(ac1, "videochat")
msg1.set_text(text)
msg1 = chat.send_msg(msg1)
assert msg1.is_videochat_invitation()
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == text
assert msg2.is_videochat_invitation()
def test_webxdc_message(acfactory, data, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = acfactory.get_accepted_chat(ac1, ac2)
@@ -432,7 +406,7 @@ def test_forward_messages(acfactory, lp):
lp.sec("ac2: check new chat has a forwarded message")
assert chat3.is_promoted()
messages = chat3.get_messages()
assert len(messages) == 2
assert len(messages) == 3
msg = messages[-1]
assert msg.is_forwarded()
ac2.delete_messages(messages)

View File

@@ -663,4 +663,4 @@ class TestOfflineChat:
lp.sec("check message count of only system messages (without daymarkers)")
sysmessages = [x for x in chat.get_messages() if x.is_system_message()]
assert len(sysmessages) == 3
assert len(sysmessages) == 4

View File

@@ -1 +1 @@
2025-07-21
2025-09-09

View File

@@ -7,7 +7,7 @@ set -euo pipefail
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.88.0
RUST_VERSION=1.89.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

View File

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

View File

@@ -17,7 +17,6 @@ pub enum EncryptPreference {
#[default]
NoPreference = 0,
Mutual = 1,
Reset = 20,
}
impl fmt::Display for EncryptPreference {
@@ -25,7 +24,6 @@ impl fmt::Display for EncryptPreference {
match *self {
EncryptPreference::Mutual => write!(fmt, "mutual"),
EncryptPreference::NoPreference => write!(fmt, "nopreference"),
EncryptPreference::Reset => write!(fmt, "reset"),
}
}
}
@@ -48,21 +46,13 @@ pub struct Aheader {
pub addr: String,
pub public_key: SignedPublicKey,
pub prefer_encrypt: EncryptPreference,
}
impl Aheader {
/// Creates new autocrypt header
pub fn new(
addr: String,
public_key: SignedPublicKey,
prefer_encrypt: EncryptPreference,
) -> Self {
Aheader {
addr,
public_key,
prefer_encrypt,
}
}
// Whether `_verified` attribute is present.
//
// `_verified` attribute is an extension to `Autocrypt-Gossip`
// header that is used to tell that the sender
// marked this key as verified.
pub verified: bool,
}
impl fmt::Display for Aheader {
@@ -71,6 +61,9 @@ impl fmt::Display for Aheader {
if self.prefer_encrypt == EncryptPreference::Mutual {
write!(fmt, " prefer-encrypt=mutual;")?;
}
if self.verified {
write!(fmt, " _verified=1;")?;
}
// adds a whitespace every 78 characters, this allows
// email crate to wrap the lines according to RFC 5322
@@ -125,6 +118,8 @@ impl FromStr for Aheader {
.and_then(|raw| raw.parse().ok())
.unwrap_or_default();
let verified = attributes.remove("_verified").is_some();
// Autocrypt-Level0: unknown attributes starting with an underscore can be safely ignored
// Autocrypt-Level0: unknown attribute, treat the header as invalid
if attributes.keys().any(|k| !k.starts_with('_')) {
@@ -135,6 +130,7 @@ impl FromStr for Aheader {
addr,
public_key,
prefer_encrypt,
verified,
})
}
}
@@ -152,10 +148,11 @@ mod tests {
assert_eq!(h.addr, "me@mail.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::Mutual);
assert_eq!(h.verified, false);
Ok(())
}
// EncryptPreference::Reset is an internal value, parser should never return it
// Non-standard values of prefer-encrypt such as `reset` are treated as no preference.
#[test]
fn test_from_str_reset() -> Result<()> {
let raw = format!("addr=reset@example.com; prefer-encrypt=reset; keydata={RAWKEY}");
@@ -245,11 +242,12 @@ mod tests {
assert!(
format!(
"{}",
Aheader::new(
"test@example.com".to_string(),
SignedPublicKey::from_base64(RAWKEY).unwrap(),
EncryptPreference::Mutual
)
Aheader {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::Mutual,
verified: false
}
)
.contains("prefer-encrypt=mutual;")
);
@@ -260,11 +258,12 @@ mod tests {
assert!(
!format!(
"{}",
Aheader::new(
"test@example.com".to_string(),
SignedPublicKey::from_base64(RAWKEY).unwrap(),
EncryptPreference::NoPreference
)
Aheader {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::NoPreference,
verified: false
}
)
.contains("prefer-encrypt")
);
@@ -273,13 +272,27 @@ mod tests {
assert!(
format!(
"{}",
Aheader::new(
"TeSt@eXaMpLe.cOm".to_string(),
SignedPublicKey::from_base64(RAWKEY).unwrap(),
EncryptPreference::Mutual
)
Aheader {
addr: "TeSt@eXaMpLe.cOm".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::Mutual,
verified: false
}
)
.contains("test@example.com")
);
assert!(
format!(
"{}",
Aheader {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::NoPreference,
verified: true
}
)
.contains("_verified")
);
}
}

373
src/calls.rs Normal file
View File

@@ -0,0 +1,373 @@
//! # Handle calls.
//!
//! Internally, calls are bound a user-visible message initializing the call.
//! This means, the "Call ID" is a "Message ID" - similar to Webxdc IDs.
use crate::chat::{Chat, ChatId, send_msg};
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::info;
use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
use crate::tools::time;
use anyhow::{Result, ensure};
use std::time::Duration;
use tokio::task;
use tokio::time::sleep;
/// How long callee's or caller's phone ring.
///
/// For the callee, this is to prevent endless ringing
/// in case the initial "call" is received, but then the caller went offline.
/// Moreover, this prevents outdated calls to ring
/// in case the initial "call" message arrives delayed.
///
/// For the caller, this means they should also not wait longer,
/// as the callee won't start the call afterwards.
const RINGING_SECONDS: i64 = 60;
/// For persisting parameters in the call, we use Param::Arg*
const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg;
const CALL_ENDED_TIMESTAMP: Param = Param::Arg4;
/// Information about the status of a call.
#[derive(Debug, Default)]
pub struct CallInfo {
/// User-defined text as given to place_outgoing_call()
pub place_call_info: String,
/// User-defined text as given to accept_incoming_call()
pub accept_call_info: String,
/// Message referring to the call.
/// Data are persisted along with the message using Param::Arg*
pub msg: Message,
}
impl CallInfo {
fn is_incoming(&self) -> bool {
self.msg.from_id != ContactId::SELF
}
fn is_stale(&self) -> bool {
self.remaining_ring_seconds() <= 0
}
fn remaining_ring_seconds(&self) -> i64 {
let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time();
remaining_seconds.clamp(0, RINGING_SECONDS)
}
async fn update_text(&self, context: &Context, text: &str) -> Result<()> {
context
.sql
.execute(
"UPDATE msgs SET txt=?, txt_normalized=? WHERE id=?",
(text, message::normalize_text(text), self.msg.id),
)
.await?;
Ok(())
}
async fn update_text_duration(&self, context: &Context) -> Result<()> {
let minutes = self.get_duration_seconds() / 60;
let duration = match minutes {
0 => "<1 minute".to_string(),
1 => "1 minute".to_string(),
n => format!("{} minutes", n),
};
if self.is_incoming() {
self.update_text(context, &format!("Incoming call\n{duration}"))
.await?;
} else {
self.update_text(context, &format!("Outgoing call\n{duration}"))
.await?;
}
Ok(())
}
/// Mark calls as accepted.
/// This is needed for all devices where a stale-timer runs, to prevent accepted calls being terminated as stale.
async fn mark_as_accepted(&mut self, context: &Context) -> Result<()> {
self.msg.param.set_i64(CALL_ACCEPTED_TIMESTAMP, time());
self.msg.update_param(context).await?;
Ok(())
}
fn is_accepted(&self) -> bool {
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
}
async fn mark_as_ended(&mut self, context: &Context) -> Result<()> {
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, time());
self.msg.update_param(context).await?;
Ok(())
}
fn is_ended(&self) -> bool {
self.msg.param.exists(CALL_ENDED_TIMESTAMP)
}
fn get_duration_seconds(&self) -> i64 {
if let (Some(start), Some(end)) = (
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
self.msg.param.get_i64(CALL_ENDED_TIMESTAMP),
) {
let seconds = end - start;
if seconds <= 0 {
return 1;
}
return seconds;
}
0
}
}
impl Context {
/// Start an outgoing call.
pub async fn place_outgoing_call(
&self,
chat_id: ChatId,
place_call_info: String,
) -> Result<MsgId> {
let chat = Chat::load_from_db(self, chat_id).await?;
ensure!(chat.typ == Chattype::Single && !chat.is_self_talk());
let mut call = Message {
viewtype: Viewtype::Call,
text: "Outgoing call".into(),
..Default::default()
};
call.param.set(Param::WebrtcRoom, &place_call_info);
call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS;
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
wait.try_into()?,
call.id,
));
Ok(call.id)
}
/// Accept an incoming call.
pub async fn accept_incoming_call(
&self,
call_id: MsgId,
accept_call_info: String,
) -> Result<()> {
let mut call: CallInfo = self.load_call_by_id(call_id).await?;
ensure!(call.is_incoming());
if call.is_accepted() || call.is_ended() {
info!(self, "Call already accepted/ended");
return Ok(());
}
call.mark_as_accepted(self).await?;
let chat = Chat::load_from_db(self, call.msg.chat_id).await?;
if chat.is_contact_request() {
chat.id.accept(self).await?;
}
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
viewtype: Viewtype::Text,
text: "[Call accepted]".into(),
..Default::default()
};
msg.param.set_cmd(SystemMessage::CallAccepted);
msg.hidden = true;
msg.param
.set(Param::WebrtcAccepted, accept_call_info.to_string());
msg.set_quote(self, Some(&call.msg)).await?;
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
}
/// Cancel, decline or hangup an incoming or outgoing call.
pub async fn end_call(&self, call_id: MsgId) -> Result<()> {
let mut call: CallInfo = self.load_call_by_id(call_id).await?;
if call.is_ended() {
info!(self, "Call already ended");
return Ok(());
}
call.mark_as_ended(self).await?;
if !call.is_accepted() {
if call.is_incoming() {
call.update_text(self, "Declined call").await?;
} else {
call.update_text(self, "Cancelled call").await?;
}
} else {
call.update_text_duration(self).await?;
}
let mut msg = Message {
viewtype: Viewtype::Text,
text: "[Call ended]".into(),
..Default::default()
};
msg.param.set_cmd(SystemMessage::CallEnded);
msg.hidden = true;
msg.set_quote(self, Some(&call.msg)).await?;
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
self.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
}
async fn emit_end_call_if_unaccepted(
context: Context,
wait: u64,
call_id: MsgId,
) -> Result<()> {
sleep(Duration::from_secs(wait)).await;
let mut call = context.load_call_by_id(call_id).await?;
if !call.is_accepted() && !call.is_ended() {
call.mark_as_ended(&context).await?;
if call.is_incoming() {
call.update_text(&context, "Missed call").await?;
} else {
call.update_text(&context, "Cancelled call").await?;
}
context.emit_msgs_changed(call.msg.chat_id, call_id);
context.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
});
}
Ok(())
}
pub(crate) async fn handle_call_msg(
&self,
call_id: MsgId,
mime_message: &MimeMessage,
from_id: ContactId,
) -> Result<()> {
if mime_message.is_call() {
let call = self.load_call_by_id(call_id).await?;
if call.is_incoming() {
if call.is_stale() {
call.update_text(self, "Missed call").await?;
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
} else {
call.update_text(self, "Incoming call").await?;
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id,
place_call_info: call.place_call_info.to_string(),
});
let wait = call.remaining_ring_seconds();
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
wait.try_into()?,
call.msg.id,
));
}
} else {
call.update_text(self, "Outgoing call").await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
}
} else {
match mime_message.is_system_message {
SystemMessage::CallAccepted => {
let mut call = self.load_call_by_id(call_id).await?;
if call.is_ended() || call.is_accepted() {
info!(self, "CallAccepted received for accepted/ended call");
return Ok(());
}
call.mark_as_accepted(self).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
if call.is_incoming() {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
});
} else {
let accept_call_info = mime_message
.get_header(HeaderDef::ChatWebrtcAccepted)
.unwrap_or_default();
self.emit_event(EventType::OutgoingCallAccepted {
msg_id: call.msg.id,
accept_call_info: accept_call_info.to_string(),
});
}
}
SystemMessage::CallEnded => {
let mut call = self.load_call_by_id(call_id).await?;
if call.is_ended() {
// may happen eg. if a a message is missed
info!(self, "CallEnded received for ended call");
return Ok(());
}
call.mark_as_ended(self).await?;
if !call.is_accepted() {
if call.is_incoming() {
if from_id == ContactId::SELF {
call.update_text(self, "Declined call").await?;
} else {
call.update_text(self, "Missed call").await?;
}
} else {
// outgoing
if from_id == ContactId::SELF {
call.update_text(self, "Cancelled call").await?;
} else {
call.update_text(self, "Declined call").await?;
}
}
} else {
call.update_text_duration(self).await?;
}
self.emit_msgs_changed(call.msg.chat_id, call_id);
self.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
});
}
_ => {}
}
}
Ok(())
}
async fn load_call_by_id(&self, call_id: MsgId) -> Result<CallInfo> {
let call = Message::load_from_db(self, call_id).await?;
self.load_call_by_message(call)
}
fn load_call_by_message(&self, call: Message) -> Result<CallInfo> {
ensure!(call.viewtype == Viewtype::Call);
Ok(CallInfo {
place_call_info: call
.param
.get(Param::WebrtcRoom)
.unwrap_or_default()
.to_string(),
accept_call_info: call
.param
.get(Param::WebrtcAccepted)
.unwrap_or_default()
.to_string(),
msg: call,
})
}
}
#[cfg(test)]
mod calls_tests;

415
src/calls/calls_tests.rs Normal file
View File

@@ -0,0 +1,415 @@
use super::*;
use crate::config::Config;
use crate::test_utils::{TestContext, TestContextManager};
struct CallSetup {
pub alice: TestContext,
pub alice2: TestContext,
pub alice_call: Message,
pub alice2_call: Message,
pub bob: TestContext,
pub bob2: TestContext,
pub bob_call: Message,
pub bob2_call: Message,
}
async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()> {
assert_eq!(Message::load_from_db(t, call_id).await?.text, text);
Ok(())
}
async fn setup_call() -> Result<CallSetup> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let alice2 = tcm.alice().await;
let bob = tcm.bob().await;
let bob2 = tcm.bob().await;
for t in [&alice, &alice2, &bob, &bob2] {
t.set_config_bool(Config::SyncMsgs, true).await?;
}
// Alice creates a chat with Bob and places an outgoing call there.
// Alice's other device sees the same message as an outgoing call.
let alice_chat = alice.create_chat(&bob).await;
let test_msg_id = alice
.place_outgoing_call(alice_chat.id, "place-info-123".to_string())
.await?;
let sent1 = alice.pop_sent_msg().await;
assert_eq!(sent1.sender_msg_id, test_msg_id);
let alice_call = Message::load_from_db(&alice, sent1.sender_msg_id).await?;
let alice2_call = alice2.recv_msg(&sent1).await;
for (t, m) in [(&alice, &alice_call), (&alice2, &alice2_call)] {
assert!(!m.is_info());
assert_eq!(m.viewtype, Viewtype::Call);
let info = t.load_call_by_id(m.id).await?;
assert!(!info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, "place-info-123");
assert_text(t, m.id, "Outgoing call").await?;
}
// Bob receives the message referring to the call on two devices;
// it is an incoming call from the view of Bob
let bob_call = bob.recv_msg(&sent1).await;
let bob2_call = bob2.recv_msg(&sent1).await;
for (t, m) in [(&bob, &bob_call), (&bob2, &bob2_call)] {
assert!(!m.is_info());
assert_eq!(m.viewtype, Viewtype::Call);
t.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCall { .. }))
.await;
let info = t.load_call_by_id(m.id).await?;
assert!(info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, "place-info-123");
assert_text(t, m.id, "Incoming call").await?;
}
Ok(CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
})
}
async fn accept_call() -> Result<CallSetup> {
let CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
} = setup_call().await?;
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, "accept-info-456".to_string())
.await?;
assert_text(&bob, bob_call.id, "Incoming call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob.load_call_by_id(bob_call.id).await?;
assert!(info.is_accepted());
assert_eq!(info.place_call_info, "place-info-123");
bob2.recv_msg_trash(&sent2).await;
assert_text(&bob, bob_call.id, "Incoming call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let info = bob2.load_call_by_id(bob2_call.id).await?;
assert!(info.is_accepted());
// Alice receives the acceptance message
alice.recv_msg_trash(&sent2).await;
assert_text(&alice, alice_call.id, "Outgoing call").await?;
let ev = alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
.await;
assert_eq!(
ev,
EventType::OutgoingCallAccepted {
msg_id: alice2_call.id,
accept_call_info: "accept-info-456".to_string()
}
);
let info = alice.load_call_by_id(alice_call.id).await?;
assert!(info.is_accepted());
assert_eq!(info.place_call_info, "place-info-123");
alice2.recv_msg_trash(&sent2).await;
assert_text(&alice2, alice2_call.id, "Outgoing call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
.await;
Ok(CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_accept_call_callee_ends() -> Result<()> {
// Alice calls Bob, Bob accepts
let CallSetup {
alice,
alice_call,
alice2,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
..
} = accept_call().await?;
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = bob.pop_sent_msg().await;
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
// Alice receives the ending message
alice.recv_msg_trash(&sent3).await;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_accept_call_caller_ends() -> Result<()> {
// Alice calls Bob, Bob accepts
let CallSetup {
alice,
alice_call,
alice2,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
..
} = accept_call().await?;
// Bob has accepted the call but Alice ends it
alice.end_call(alice_call.id).await?;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = alice.pop_sent_msg().await;
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
// Bob receives the ending message
bob.recv_msg_trash(&sent3).await;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_rejects_call() -> Result<()> {
// Alice calls Bob
let CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
..
} = 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_text(&bob, bob_call.id, "Declined call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = bob.pop_sent_msg().await;
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Declined call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
// Alice receives decline message
alice.recv_msg_trash(&sent3).await;
assert_text(&alice, alice_call.id, "Declined call").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Declined call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob
let CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
..
} = setup_call().await?;
// Alice changes their mind before Bob picks up
alice.end_call(alice_call.id).await?;
assert_text(&alice, alice_call.id, "Cancelled call").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = alice.pop_sent_msg().await;
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Cancelled call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
// Bob receives the ending message
bob.recv_msg_trash(&sent3).await;
assert_text(&bob, bob_call.id, "Missed call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Missed call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_stale_call() -> Result<()> {
// a call started now is not stale
let call_info = CallInfo {
msg: Message {
timestamp_sent: time(),
..Default::default()
},
..Default::default()
};
assert!(!call_info.is_stale());
let remaining_seconds = call_info.remaining_ring_seconds();
assert!(remaining_seconds == RINGING_SECONDS || remaining_seconds == RINGING_SECONDS - 1);
// call started 5 seconds ago, this is not stale as well
let call_info = CallInfo {
msg: Message {
timestamp_sent: time() - 5,
..Default::default()
},
..Default::default()
};
assert!(!call_info.is_stale());
let remaining_seconds = call_info.remaining_ring_seconds();
assert!(remaining_seconds == RINGING_SECONDS - 5 || remaining_seconds == RINGING_SECONDS - 6);
// a call started one hour ago is clearly stale
let call_info = CallInfo {
msg: Message {
timestamp_sent: time() - 3600,
..Default::default()
},
..Default::default()
};
assert!(call_info.is_stale());
assert_eq!(call_info.remaining_ring_seconds(), 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mark_calls() -> Result<()> {
let CallSetup {
alice, alice_call, ..
} = setup_call().await?;
let mut call_info: CallInfo = alice.load_call_by_id(alice_call.id).await?;
assert!(!call_info.is_accepted());
assert!(!call_info.is_ended());
call_info.mark_as_accepted(&alice).await?;
assert!(call_info.is_accepted());
assert!(!call_info.is_ended());
let mut call_info: CallInfo = alice.load_call_by_id(alice_call.id).await?;
assert!(call_info.is_accepted());
assert!(!call_info.is_ended());
call_info.mark_as_ended(&alice).await?;
assert!(call_info.is_accepted());
assert!(call_info.is_ended());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_udpate_call_text() -> Result<()> {
let CallSetup {
alice, alice_call, ..
} = setup_call().await?;
let call_info = alice.load_call_by_id(alice_call.id).await?;
call_info.update_text(&alice, "foo bar").await?;
let alice_call = Message::load_from_db(&alice, alice_call.id).await?;
assert_eq!(alice_call.get_text(), "foo bar");
Ok(())
}

View File

@@ -43,9 +43,9 @@ use crate::smtp::send_msg_to_smtp;
use crate::stock_str;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
IsNoneOrEmpty, SystemTime, buf_compress, create_id, create_outgoing_rfc724_mid,
create_smeared_timestamp, create_smeared_timestamps, get_abs_path, gm2local_offset,
smeared_time, time, truncate_msg_text,
IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_shared_secret, create_id,
create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path,
gm2local_offset, smeared_time, time, truncate_msg_text,
};
use crate::webxdc::StatusUpdateSerial;
use crate::{chatlist_events, imap};
@@ -94,14 +94,12 @@ pub enum ProtectionStatus {
///
/// All members of the chat must be verified.
Protected = 1,
// `2` was never used as a value.
/// The chat was protected, but now a new message came in
/// which was not encrypted / signed correctly.
/// The user has to confirm that this is OK.
///
/// We only do this in 1:1 chats; in group chats, the chat just
/// stays protected.
ProtectionBroken = 3, // `2` was never used as a value.
// Chats don't break in Core v2 anymore. Chats with broken protection existing before the
// key-contacts migration are treated as `Unprotected`.
//
// ProtectionBroken = 3,
}
/// The reason why messages cannot be sent to the chat.
@@ -118,10 +116,6 @@ pub(crate) enum CantSendReason {
/// The chat is a contact request, it needs to be accepted before sending a message.
ContactRequest,
/// The chat was protected, but now a new message came in
/// which was not encrypted / signed correctly.
ProtectionBroken,
/// Mailing list without known List-Post header.
ReadOnlyMailingList,
@@ -144,10 +138,6 @@ impl fmt::Display for CantSendReason {
f,
"contact request chat should be accepted before sending messages"
),
Self::ProtectionBroken => write!(
f,
"accept that the encryption isn't verified anymore before sending messages"
),
Self::ReadOnlyMailingList => {
write!(f, "mailing list does not have a know post address")
}
@@ -479,16 +469,6 @@ impl ChatId {
let chat = Chat::load_from_db(context, self).await?;
match chat.typ {
Chattype::Single
if chat.blocked == Blocked::Not
&& chat.protected == ProtectionStatus::ProtectionBroken =>
{
// The protection was broken, then the user clicked 'Accept'/'OK',
// so, now we want to set the status to Unprotected again:
chat.id
.inner_set_protection(context, ProtectionStatus::Unprotected)
.await?;
}
Chattype::Single | Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast => {
// User has "created a chat" with all these contacts.
//
@@ -545,7 +525,7 @@ impl ChatId {
| Chattype::InBroadcast => {}
Chattype::Mailinglist => bail!("Cannot protect mailing lists"),
},
ProtectionStatus::Unprotected | ProtectionStatus::ProtectionBroken => {}
ProtectionStatus::Unprotected => {}
};
context
@@ -588,7 +568,6 @@ impl ChatId {
let cmd = match protect {
ProtectionStatus::Protected => SystemMessage::ChatProtectionEnabled,
ProtectionStatus::Unprotected => SystemMessage::ChatProtectionDisabled,
ProtectionStatus::ProtectionBroken => SystemMessage::ChatProtectionDisabled,
};
add_info_msg_with_cmd(
context,
@@ -621,11 +600,23 @@ impl ChatId {
|| chat.is_device_talk()
|| chat.is_self_talk()
|| (!chat.can_send(context).await? && !chat.is_contact_request())
// For chattype InBrodacast, the info message is added when the member-added message is received
// by directly calling add_encrypted_msg()
|| chat.typ == Chattype::InBroadcast
|| chat.blocked == Blocked::Yes
{
return Ok(());
}
self.add_encrypted_msg(context, timestamp_sort).await?;
Ok(())
}
pub(crate) async fn add_encrypted_msg(
self,
context: &Context,
timestamp_sort: i64,
) -> Result<()> {
let text = stock_str::messages_e2e_encrypted(context).await;
add_info_msg_with_cmd(
context,
@@ -1700,12 +1691,6 @@ impl Chat {
return Ok(Some(reason));
}
}
if self.is_protection_broken() {
let reason = ProtectionBroken;
if !skip_fn(&reason) {
return Ok(Some(reason));
}
}
if self.is_mailing_list() && self.get_mailinglist_addr().is_none_or_empty() {
let reason = ReadOnlyMailingList;
if !skip_fn(&reason) {
@@ -1752,8 +1737,9 @@ impl Chat {
pub(crate) async fn is_self_in_chat(&self, context: &Context) -> Result<bool> {
match self.typ {
Chattype::Single | Chattype::OutBroadcast | Chattype::Mailinglist => Ok(true),
Chattype::Group => is_contact_in_chat(context, self.id, ContactId::SELF).await,
Chattype::InBroadcast => Ok(false),
Chattype::Group | Chattype::InBroadcast => {
is_contact_in_chat(context, self.id, ContactId::SELF).await
}
}
}
@@ -1798,6 +1784,12 @@ impl Chat {
return Ok(Some(get_device_icon(context).await?));
} else if self.is_self_talk() {
return Ok(Some(get_saved_messages_icon(context).await?));
} else if !self.is_encrypted(context).await? {
// This is an unencrypted chat, show a special avatar that marks it as such.
return Ok(Some(get_abs_path(
context,
Path::new(&get_unencrypted_icon(context).await?),
)));
} else if self.typ == Chattype::Single {
// For 1:1 chats, we always use the same avatar as for the contact
// This is before the `self.is_encrypted()` check, because that function
@@ -1807,12 +1799,6 @@ impl Chat {
let contact = Contact::get_by_id(context, *contact_id).await?;
return contact.get_profile_image(context).await;
}
} else if !self.is_encrypted(context).await? {
// This is an address-contact chat, show a special avatar that marks it as such
return Ok(Some(get_abs_path(
context,
Path::new(&get_address_contact_icon(context).await?),
)));
} else if let Some(image_rel) = self.param.get(Param::ProfileImage) {
// Load the group avatar, or the device-chat / saved-messages icon
if !image_rel.is_empty() {
@@ -1824,8 +1810,9 @@ impl Chat {
/// Returns chat avatar color.
///
/// For 1:1 chats, the color is calculated from the contact's address.
/// For group chats the color is calculated from the chat name.
/// For 1:1 chats, the color is calculated from the contact's address
/// for address-contacts and from the OpenPGP key fingerprint for key-contacts.
/// For group chats the color is calculated from the grpid, if present, or the chat name.
pub async fn get_color(&self, context: &Context) -> Result<u32> {
let mut color = 0;
@@ -1836,6 +1823,8 @@ impl Chat {
color = contact.get_color();
}
}
} else if !self.grpid.is_empty() {
color = str_to_color(&self.grpid);
} else {
color = str_to_color(&self.name);
}
@@ -1913,16 +1902,25 @@ impl Chat {
let is_encrypted = self.is_protected()
|| match self.typ {
Chattype::Single => {
let chat_contact_ids = get_chat_contacts(context, self.id).await?;
if let Some(contact_id) = chat_contact_ids.first() {
if *contact_id == ContactId::DEVICE {
true
} else {
let contact = Contact::get_by_id(context, *contact_id).await?;
contact.is_key_contact()
}
} else {
true
match context
.sql
.query_row_optional(
"SELECT cc.contact_id, c.fingerprint<>''
FROM chats_contacts cc LEFT JOIN contacts c
ON c.id=cc.contact_id
WHERE cc.chat_id=?
",
(self.id,),
|row| {
let id: ContactId = row.get(0)?;
let is_key: bool = row.get(1)?;
Ok((id, is_key))
},
)
.await?
{
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
None => true,
}
}
Chattype::Group => {
@@ -1935,27 +1933,6 @@ impl Chat {
Ok(is_encrypted)
}
/// Returns true if the chat was protected, and then an incoming message broke this protection.
///
/// This function is only useful if the UI enabled the `verified_one_on_one_chats` feature flag,
/// otherwise it will return false for all chats.
///
/// 1:1 chats are automatically set as protected when a contact is verified.
/// When a message comes in that is not encrypted / signed correctly,
/// the chat is automatically set as unprotected again.
/// `is_protection_broken()` will return true until `chat_id.accept()` is called.
///
/// The UI should let the user confirm that this is OK with a message like
/// `Bob sent a message from another device. Tap to learn more`
/// and then call `chat_id.accept()`.
pub fn is_protection_broken(&self) -> bool {
match self.protected {
ProtectionStatus::Protected => false,
ProtectionStatus::Unprotected => false,
ProtectionStatus::ProtectionBroken => true,
}
}
/// Returns true if location streaming is enabled in the chat.
pub fn is_sending_locations(&self) -> bool {
self.is_sending_locations
@@ -1993,7 +1970,7 @@ impl Chat {
}
/// Adds missing values to the msg object,
/// writes the record to the database and returns its msg_id.
/// writes the record to the database.
///
/// If `update_msg_id` is set, that record is reused;
/// if `update_msg_id` is None, a new record is created.
@@ -2002,7 +1979,7 @@ impl Chat {
context: &Context,
msg: &mut Message,
update_msg_id: Option<MsgId>,
) -> Result<MsgId> {
) -> Result<()> {
let mut to_id = 0;
let mut location_id = 0;
@@ -2280,7 +2257,7 @@ impl Chat {
.await?;
}
context.scheduler.interrupt_ephemeral_task().await;
Ok(msg.id)
Ok(())
}
/// Sends a `SyncAction` synchronising chat contacts to other devices.
@@ -2533,11 +2510,13 @@ pub(crate) async fn get_archive_icon(context: &Context) -> Result<PathBuf> {
.await
}
pub(crate) async fn get_address_contact_icon(context: &Context) -> Result<PathBuf> {
/// Returns path to the icon
/// indicating unencrypted chats and address-contacts.
pub(crate) async fn get_unencrypted_icon(context: &Context) -> Result<PathBuf> {
get_asset_icon(
context,
"icon-address-contact",
include_bytes!("../assets/icon-address-contact.png"),
"icon-unencrypted",
include_bytes!("../assets/icon-unencrypted.png"),
)
.await
}
@@ -2725,7 +2704,10 @@ impl ChatIdBlocked {
}
async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
if msg.viewtype == Viewtype::Text || msg.viewtype == Viewtype::VideochatInvitation {
if msg.viewtype == Viewtype::Text
|| msg.viewtype == Viewtype::VideochatInvitation
|| msg.viewtype == Viewtype::Call
{
// the caller should check if the message text is empty
} else if msg.viewtype.has_file() {
let viewtype_orig = msg.viewtype;
@@ -2864,8 +2846,9 @@ pub async fn is_contact_in_chat(
) -> Result<bool> {
// this function works for group and for normal chats, however, it is more useful
// for group chats.
// ContactId::SELF may be used to check, if the user itself is in a group
// chat (ContactId::SELF is not added to normal chats)
// ContactId::SELF may be used to check whether oneself
// is in a group or incoming broadcast chat
// (ContactId::SELF is not added to 1:1 chats or outgoing broadcast channels)
let exists = context
.sql
@@ -2947,7 +2930,7 @@ async fn prepare_send_msg(
let mut chat = Chat::load_from_db(context, chat_id).await?;
let skip_fn = |reason: &CantSendReason| match reason {
CantSendReason::ProtectionBroken | CantSendReason::ContactRequest => {
CantSendReason::ContactRequest => {
// Allow securejoin messages, they are supposed to repair the verification.
// If the chat is a contact request, let the user accept it later.
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
@@ -2955,13 +2938,20 @@ async fn prepare_send_msg(
// Allow to send "Member removed" messages so we can leave the group/broadcast.
// Necessary checks should be made anyway before removing contact
// from the chat.
CantSendReason::NotAMember | CantSendReason::InBroadcast => {
msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup
CantSendReason::NotAMember => msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup,
CantSendReason::InBroadcast => {
matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
)
}
CantSendReason::MissingKey => {
msg.param
.get_bool(Param::ForcePlaintext)
.unwrap_or_default()
// "vb-request-with-auth" is symmetrically encrypted, no need for the public key:
|| msg.is_vb_request_with_auth()
}
CantSendReason::MissingKey => msg
.param
.get_bool(Param::ForcePlaintext)
.unwrap_or_default(),
_ => false,
};
if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
@@ -3003,8 +2993,7 @@ async fn prepare_send_msg(
if !msg.hidden {
chat_id.unarchive_if_not_muted(context, msg.state).await?;
}
msg.id = chat.prepare_msg_raw(context, msg, update_msg_id).await?;
msg.chat_id = chat_id;
chat.prepare_msg_raw(context, msg, update_msg_id).await?;
let row_ids = create_send_msg_jobs(context, msg)
.await
@@ -3017,6 +3006,10 @@ async fn prepare_send_msg(
/// Constructs jobs for sending a message and inserts them into the appropriate table.
///
/// Updates the message `GuaranteeE2ee` parameter and persists it
/// in the database depending on whether the message
/// is added to the outgoing queue as encrypted or not.
///
/// Returns row ids if `smtp` table jobs were created or an empty `Vec` otherwise.
///
/// The caller has to interrupt SMTP loop or otherwise process new rows.
@@ -3028,7 +3021,16 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
}
let needs_encryption = msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default();
let mimefactory = MimeFactory::from_msg(context, msg.clone()).await?;
let mimefactory = match MimeFactory::from_msg(context, msg.clone()).await {
Ok(mf) => mf,
Err(err) => {
// Mark message as failed
message::set_msg_failed(context, msg, &err.to_string())
.await
.ok();
return Err(err);
}
};
let attach_selfavatar = mimefactory.attach_selfavatar;
let mut recipients = mimefactory.recipients();
@@ -3051,6 +3053,9 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
if (context.get_config_bool(Config::BccSelf).await?
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage)
&& (context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty())
// `vb-request-with-auth` messages are symmetrically encrypted
// with a secret which the other device doesn't have:
&& !msg.is_vb_request_with_auth()
{
recipients.push(from);
}
@@ -3110,13 +3115,20 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
}
}
if rendered_msg.is_encrypted && !needs_encryption {
if rendered_msg.is_encrypted {
msg.param.set_int(Param::GuaranteeE2ee, 1);
msg.update_param(context).await?;
} else {
msg.param.remove(Param::GuaranteeE2ee);
}
msg.subject.clone_from(&rendered_msg.subject);
msg.update_subject(context).await?;
context
.sql
.execute(
"UPDATE msgs SET subject=?, param=? WHERE id=?",
(&msg.subject, msg.param.to_string(), msg.id),
)
.await?;
let chunk_size = context.get_max_smtp_rcpt_to().await?;
let trans_fn = |t: &mut rusqlite::Transaction| {
let mut row_ids = Vec::<i64>::new();
@@ -3181,6 +3193,7 @@ pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: Strin
original_msg.viewtype != Viewtype::VideochatInvitation,
"Cannot edit videochat invitations"
);
ensure!(original_msg.viewtype != Viewtype::Call, "Cannot edit calls");
ensure!(
!original_msg.text.is_empty(), // avoid complexity in UI element changes. focus is typos and rewordings
"Cannot add text"
@@ -3366,10 +3379,11 @@ pub async fn get_chat_msgs_ex(
for (ts, curr_id) in sorted_rows {
if add_daymarker {
let curr_local_timestamp = ts + cnv_to_local;
let curr_day = curr_local_timestamp / 86400;
let secs_in_day = 86400;
let curr_day = curr_local_timestamp / secs_in_day;
if curr_day != last_day {
ret.push(ChatItem::DayMarker {
timestamp: curr_day * 86400, // Convert day back to Unix timestamp
timestamp: curr_day * secs_in_day - cnv_to_local,
});
last_day = curr_day;
}
@@ -3717,8 +3731,13 @@ pub async fn create_group_ex(
encryption: Option<ProtectionStatus>,
name: &str,
) -> Result<ChatId> {
let chat_name = sanitize_single_line(name);
ensure!(!chat_name.is_empty(), "Invalid chat name");
let mut chat_name = sanitize_single_line(name);
if chat_name.is_empty() {
// We can't just fail because the user would lose the work already done in the UI like
// selecting members.
error!(context, "Invalid chat name: {name}.");
chat_name = "".to_string();
}
let grpid = match encryption {
Some(_) => create_id(),
@@ -3743,11 +3762,19 @@ pub async fn create_group_ex(
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
if encryption == Some(ProtectionStatus::Protected) {
let protect = ProtectionStatus::Protected;
chat_id
.set_protection_for_timestamp_sort(context, protect, timestamp, None)
.await?;
match encryption {
Some(ProtectionStatus::Protected) => {
let protect = ProtectionStatus::Protected;
chat_id
.set_protection_for_timestamp_sort(context, protect, timestamp, None)
.await?;
}
Some(ProtectionStatus::Unprotected) => {
// Add "Messages are end-to-end encrypted." message
// even to unprotected chats.
chat_id.maybe_add_encrypted_msg(context, timestamp).await?;
}
None => {}
}
if !context.get_config_bool(Config::Bot).await?
@@ -3760,7 +3787,7 @@ pub async fn create_group_ex(
Ok(chat_id)
}
/// Create a new **broadcast channel**
/// Create a new, outgoing **broadcast channel**
/// (called "Channel" in the UI).
///
/// Broadcast channels are similar to groups on the sending device,
@@ -3777,60 +3804,92 @@ pub async fn create_group_ex(
/// Returns the created chat's id.
pub async fn create_broadcast(context: &Context, chat_name: String) -> Result<ChatId> {
let grpid = create_id();
create_broadcast_ex(context, Sync, grpid, chat_name).await
let secret = create_broadcast_shared_secret();
create_out_broadcast_ex(context, Sync, grpid, chat_name, secret).await
}
pub(crate) async fn create_broadcast_ex(
const SQL_INSERT_BROADCAST_SECRET: &str =
"INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?)
ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.secret";
pub(crate) async fn create_out_broadcast_ex(
context: &Context,
sync: sync::Sync,
grpid: String,
chat_name: String,
secret: String,
) -> Result<ChatId> {
let row_id = {
let chat_name = &chat_name;
let grpid = &grpid;
let trans_fn = |t: &mut rusqlite::Transaction| {
let cnt = t.execute("UPDATE chats SET name=? WHERE grpid=?", (chat_name, grpid))?;
ensure!(cnt <= 1, "{cnt} chats exist with grpid {grpid}");
if cnt == 1 {
return Ok(t.query_row(
"SELECT id FROM chats WHERE grpid=? AND type=?",
(grpid, Chattype::OutBroadcast),
|row| {
let id: isize = row.get(0)?;
Ok(id)
},
)?);
}
t.execute(
"INSERT INTO chats \
(type, name, grpid, param, created_timestamp) \
VALUES(?, ?, ?, \'U=1\', ?);",
(
Chattype::OutBroadcast,
&chat_name,
&grpid,
create_smeared_timestamp(context),
),
)?;
Ok(t.last_insert_rowid().try_into()?)
};
context.sql.transaction(trans_fn).await?
let chat_name = sanitize_single_line(&chat_name);
if chat_name.is_empty() {
bail!("Invalid broadcast channel name: {chat_name}.");
}
let timestamp = create_smeared_timestamp(context);
let trans_fn = |t: &mut rusqlite::Transaction| -> Result<ChatId> {
let cnt: u32 = t.query_row(
"SELECT COUNT(*) FROM chats WHERE grpid=?",
(&grpid,),
|row| row.get(0),
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
t.execute(
"INSERT INTO chats \
(type, name, grpid, created_timestamp) \
VALUES(?, ?, ?, ?);",
(Chattype::OutBroadcast, &chat_name, &grpid, timestamp),
)?;
let chat_id = ChatId::new(t.last_insert_rowid().try_into()?);
t.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, &secret))?;
Ok(chat_id)
};
let chat_id = ChatId::new(u32::try_from(row_id)?);
let chat_id = context.sql.transaction(trans_fn).await?;
chat_id.maybe_add_encrypted_msg(context, timestamp).await?;
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
if sync.into() {
let id = SyncId::Grpid(grpid);
let action = SyncAction::CreateBroadcast(chat_name);
let action = SyncAction::CreateOutBroadcast {
chat_name,
shared_secret: secret,
};
self::sync(context, id, action).await.log_err(context).ok();
}
Ok(chat_id)
}
pub(crate) async fn load_broadcast_shared_secret(
context: &Context,
chat_id: ChatId,
) -> Result<Option<String>> {
context
.sql
.query_get_value(
"SELECT secret FROM broadcasts_shared_secrets WHERE chat_id=?",
(chat_id,),
)
.await
}
pub(crate) async fn save_broadcast_shared_secret(
context: &Context,
chat_id: ChatId,
secret: &str,
) -> Result<()> {
info!(context, "Saving broadcast secret for chat {chat_id}");
context
.sql
.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, secret))
.await?;
Ok(())
}
/// Set chat contacts in the `chats_contacts` table.
pub(crate) async fn update_chat_contacts_table(
context: &Context,
@@ -3918,6 +3977,30 @@ pub(crate) async fn remove_from_chat_contacts_table(
Ok(())
}
/// Removes a contact from the chat
/// without leaving a trace.
///
/// Note that if we call this function,
/// and then receive a message from another device
/// that doesn't know that this this member was removed
/// then the group membership algorithm will wrongly re-add this member.
pub(crate) async fn remove_from_chat_contacts_table_without_trace(
context: &Context,
chat_id: ChatId,
contact_id: ContactId,
) -> Result<()> {
context
.sql
.execute(
"DELETE FROM chats_contacts
WHERE chat_id=? AND contact_id=?",
(chat_id, contact_id),
)
.await?;
Ok(())
}
/// Adds a contact to the chat.
/// If the group is promoted, also sends out a system message to all group members
pub async fn add_contact_to_chat(
@@ -3945,8 +4028,8 @@ pub(crate) async fn add_contact_to_chat_ex(
// this also makes sure, no contacts are added to special or normal chats
let mut chat = Chat::load_from_db(context, chat_id).await?;
ensure!(
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"{} is not a group/broadcast where one can add members",
chat.typ == Chattype::Group || (from_handshake && chat.typ == Chattype::OutBroadcast),
"{} is not a group where one can add members",
chat_id
);
ensure!(
@@ -3954,7 +4037,6 @@ pub(crate) async fn add_contact_to_chat_ex(
"invalid contact_id {} for adding to group",
contact_id
);
ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed");
ensure!(
chat.typ != Chattype::OutBroadcast || contact_id != ContactId::SELF,
"Cannot add SELF to broadcast channel."
@@ -4005,21 +4087,33 @@ pub(crate) async fn add_contact_to_chat_ex(
);
return Ok(false);
}
if is_contact_in_chat(context, chat_id, contact_id).await? {
return Ok(false);
}
add_to_chat_contacts_table(context, time(), chat_id, &[contact_id]).await?;
}
if chat.typ == Chattype::Group && chat.is_promoted() {
if chat.is_promoted() {
msg.viewtype = Viewtype::Text;
let contact_addr = contact.get_addr().to_lowercase();
msg.text = stock_str::msg_add_member_local(context, contact.id, ContactId::SELF).await;
let added_by = if from_handshake && chat.typ == Chattype::OutBroadcast {
// The contact was added via a QR code rather than explicit user action,
// so it could be confusing to say 'You added member Alice'.
// And in a broadcast, SELF is the only one who can add members,
// so, no information is lost by just writing 'Member Alice added' instead.
ContactId::UNDEFINED
} else {
ContactId::SELF
};
msg.text = stock_str::msg_add_member_local(context, contact.id, added_by).await;
msg.param.set_cmd(SystemMessage::MemberAddedToGroup);
msg.param.set(Param::Arg, contact_addr);
msg.param.set_int(Param::Arg2, from_handshake.into());
msg.param
.set_int(Param::ContactAddedRemoved, contact.id.to_u32() as i32);
if chat.typ == Chattype::OutBroadcast {
let secret = load_broadcast_shared_secret(context, chat_id)
.await?
.context("Failed to find broadcast shared secret")?;
msg.param.set(Param::Arg3, secret);
}
send_msg(context, chat_id, &mut msg).await?;
sync = Nosync;
@@ -4174,7 +4268,17 @@ pub async fn remove_contact_from_chat(
);
let chat = Chat::load_from_db(context, chat_id).await?;
if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast {
if chat.typ == Chattype::InBroadcast {
ensure!(
contact_id == ContactId::SELF,
"Cannot remove other member from incoming broadcast channel"
);
}
if matches!(
chat.typ,
Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast
) {
if !chat.is_self_in_chat(context).await? {
let err_msg = format!(
"Cannot remove contact {contact_id} from chat {chat_id}: self not in group."
@@ -4187,24 +4291,25 @@ pub async fn remove_contact_from_chat(
if chat.is_promoted() {
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
} else {
context
.sql
.execute(
"DELETE FROM chats_contacts
WHERE chat_id=? AND contact_id=?",
(chat_id, contact_id),
)
.await?;
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?;
}
// We do not return an error if the contact does not exist in the database.
// This allows to delete dangling references to deleted contacts
// in case of the database becoming inconsistent due to a bug.
if let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? {
if chat.typ == Chattype::Group && chat.is_promoted() {
if chat.is_promoted() {
let addr = contact.get_addr();
let fingerprint = contact.fingerprint().map(|f| f.hex());
let res = send_member_removal_msg(context, chat_id, contact_id, addr).await;
let res = send_member_removal_msg(
context,
chat_id,
contact_id,
addr,
fingerprint.as_deref(),
)
.await;
if contact_id == ContactId::SELF {
res?;
@@ -4224,11 +4329,6 @@ pub async fn remove_contact_from_chat(
chat.sync_contacts(context).await.log_err(context).ok();
}
}
} else if chat.typ == Chattype::InBroadcast && contact_id == ContactId::SELF {
// For incoming broadcast channels, it's not possible to remove members,
// but it's possible to leave:
let self_addr = context.get_primary_self_addr().await?;
send_member_removal_msg(context, chat_id, contact_id, &self_addr).await?;
} else {
bail!("Cannot remove members from non-group chats.");
}
@@ -4241,6 +4341,7 @@ async fn send_member_removal_msg(
chat_id: ChatId,
contact_id: ContactId,
addr: &str,
fingerprint: Option<&str>,
) -> Result<MsgId> {
let mut msg = Message::new(Viewtype::Text);
@@ -4252,6 +4353,7 @@ async fn send_member_removal_msg(
msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup);
msg.param.set(Param::Arg, addr.to_lowercase());
msg.param.set_optional(Param::Arg2, fingerprint);
msg.param
.set(Param::ContactAddedRemoved, contact_id.to_u32());
@@ -4460,13 +4562,13 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
msg.state = MessageState::OutPending;
msg.rfc724_mid = create_outgoing_rfc724_mid();
msg.timestamp_sort = curr_timestamp;
let new_msg_id = chat.prepare_msg_raw(context, &mut msg, None).await?;
chat.prepare_msg_raw(context, &mut msg, None).await?;
curr_timestamp += 1;
if !create_send_msg_jobs(context, &mut msg).await?.is_empty() {
context.scheduler.interrupt_smtp().await;
}
created_msgs.push(new_msg_id);
created_msgs.push(msg.id);
}
for msg_id in created_msgs {
context.emit_msgs_changed(chat_id, msg_id);
@@ -4523,15 +4625,24 @@ pub(crate) async fn save_copy_in_self_talk(
bail!("message already saved.");
}
let copy_fields = "from_id, to_id, timestamp_sent, timestamp_rcvd, type, txt, \
mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg";
let copy_fields = "from_id, to_id, timestamp_rcvd, type, txt,
mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg";
let row_id = context
.sql
.insert(
&format!(
"INSERT INTO msgs ({copy_fields}, chat_id, rfc724_mid, state, timestamp, param, starred) \
SELECT {copy_fields}, ?, ?, ?, ?, ?, ? \
FROM msgs WHERE id=?;"
"INSERT INTO msgs ({copy_fields},
timestamp_sent,
chat_id, rfc724_mid, state, timestamp, param, starred)
SELECT {copy_fields},
-- Outgoing messages on originating device
-- have timestamp_sent == 0.
-- We copy sort timestamp instead
-- so UIs display the same timestamp
-- for saved and original message.
IIF(timestamp_sent == 0, timestamp, timestamp_sent),
?, ?, ?, ?, ?, ?
FROM msgs WHERE id=?;"
),
(
dest_chat_id,
@@ -4562,18 +4673,9 @@ pub(crate) async fn save_copy_in_self_talk(
///
/// This is primarily intended to make existing webxdcs available to new chat members.
pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
let mut chat_id = None;
let mut msgs: Vec<Message> = Vec::new();
for msg_id in msg_ids {
let msg = Message::load_from_db(context, *msg_id).await?;
if let Some(chat_id) = chat_id {
ensure!(
chat_id == msg.chat_id,
"messages to resend needs to be in the same chat"
);
} else {
chat_id = Some(msg.chat_id);
}
ensure!(
msg.from_id == ContactId::SELF,
"can resend only own messages"
@@ -4582,16 +4684,7 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
msgs.push(msg)
}
let Some(chat_id) = chat_id else {
return Ok(());
};
let chat = Chat::load_from_db(context, chat_id).await?;
for mut msg in msgs {
if msg.get_showpadlock() && !chat.is_protected() {
msg.param.remove(Param::GuaranteeE2ee);
msg.update_param(context).await?;
}
match msg.get_state() {
// `get_state()` may return an outdated `OutPending`, so update anyway.
MessageState::OutPending
@@ -4602,16 +4695,21 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
}
msg_state => bail!("Unexpected message state {msg_state}"),
}
msg.timestamp_sort = create_smeared_timestamp(context);
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
continue;
}
// Emit the event only after `create_send_msg_jobs`
// because `create_send_msg_jobs` may change the message
// encryption status and call `msg.update_param`.
context.emit_event(EventType::MsgsChanged {
chat_id: msg.chat_id,
msg_id: msg.id,
});
msg.timestamp_sort = create_smeared_timestamp(context);
// note(treefit): only matters if it is the last message in chat (but probably to expensive to check, debounce also solves it)
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
continue;
}
if msg.viewtype == Viewtype::Webxdc {
let conn_fn = |conn: &mut rusqlite::Connection| {
let range = conn.query_row(
@@ -5040,7 +5138,12 @@ pub(crate) enum SyncAction {
SetVisibility(ChatVisibility),
SetMuted(MuteDuration),
/// Create broadcast channel with the given name.
CreateBroadcast(String),
CreateOutBroadcast {
chat_name: String,
shared_secret: String,
},
/// Mark the contact with the given fingerprint as verified by self.
MarkVerified,
Rename(String),
/// Set chat contacts by their addresses.
SetContacts(Vec<String>),
@@ -5096,6 +5199,16 @@ impl Context {
SyncAction::Unblock => {
return contact::set_blocked(self, Nosync, contact_id, false).await;
}
SyncAction::MarkVerified => {
ContactId::scaleup_origin(self, &[contact_id], Origin::SecurejoinJoined)
.await?;
return contact::mark_contact_id_as_verified(
self,
contact_id,
Some(ContactId::SELF),
)
.await;
}
_ => (),
}
ChatIdBlocked::get_for_contact(self, contact_id, Blocked::Request)
@@ -5103,8 +5216,8 @@ impl Context {
.id
}
SyncId::Grpid(grpid) => {
if let SyncAction::CreateBroadcast(name) = action {
create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?;
let handled = self.handle_sync_create_chat(action, grpid).await?;
if handled {
return Ok(());
}
get_chat_id_by_grpid(self, grpid)
@@ -5127,7 +5240,9 @@ impl Context {
SyncAction::Accept => chat_id.accept_ex(self, Nosync).await,
SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await,
SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await,
SyncAction::CreateBroadcast(_) => {
SyncAction::CreateOutBroadcast { .. } | SyncAction::MarkVerified => {
// Create action should have been handled by handle_sync_create_chat() already.
// MarkVerified action should have been handled by mark_contact_id_as_verified() already.
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
}
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,
@@ -5139,6 +5254,26 @@ impl Context {
}
}
async fn handle_sync_create_chat(&self, action: &SyncAction, grpid: &str) -> Result<bool> {
match action {
SyncAction::CreateOutBroadcast {
chat_name,
shared_secret,
} => {
create_out_broadcast_ex(
self,
Nosync,
grpid.to_string(),
chat_name.clone(),
shared_secret.to_string(),
)
.await?;
Ok(true)
}
_ => Ok(false),
}
}
/// Emits the appropriate `MsgsChanged` event. Should be called if the number of unnoticed
/// archived chats could decrease. In general we don't want to make an extra db query to know if
/// a noticed chat is archived. Emitting events should be cheap, a false-positive `MsgsChanged`

View File

@@ -1,3 +1,5 @@
use std::sync::Arc;
use super::*;
use crate::chatlist::get_archived_cnt;
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
@@ -7,11 +9,14 @@ use crate::imex::{ImexMode, has_backup, imex};
use crate::message::{MessengerMessage, delete_msgs};
use crate::mimeparser::{self, MimeMessage};
use crate::receive_imf::receive_imf;
use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, sync,
};
use crate::tools::SystemTime;
use pretty_assertions::assert_eq;
use std::time::Duration;
use strum::IntoEnumIterator;
use tokio::fs;
@@ -32,7 +37,7 @@ async fn test_chat_info() {
"archived": false,
"param": "",
"is_sending_locations": false,
"color": 35391,
"color": 29377,
"profile_image": {},
"draft": "",
"is_muted": false,
@@ -1644,7 +1649,7 @@ async fn test_set_mute_duration() {
async fn test_add_info_msg() -> Result<()> {
let t = TestContext::new().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
add_info_msg(&t, chat_id, "foo info", 200000).await?;
add_info_msg(&t, chat_id, "foo info", time()).await?;
let msg = t.get_last_msg_in(chat_id).await;
assert_eq!(msg.get_chat_id(), chat_id);
@@ -1666,7 +1671,7 @@ async fn test_add_info_msg_with_cmd() -> Result<()> {
chat_id,
"foo bar info",
SystemMessage::EphemeralTimerChanged,
10000,
time(),
None,
None,
None,
@@ -1929,19 +1934,31 @@ async fn test_classic_email_chat() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_get_color() -> Result<()> {
let t = TestContext::new().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat").await?;
let chat_id = create_group_ex(&t, None, "a chat").await?;
let color1 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?;
assert_eq!(color1, 0x008772);
assert_eq!(color1, 0x613dd7);
// upper-/lowercase makes a difference for the colors, these are different groups
// (in contrast to email addresses, where upper-/lowercase is ignored in practise)
let t = TestContext::new().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "A CHAT").await?;
let chat_id = create_group_ex(&t, None, "A CHAT").await?;
let color2 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?;
assert_ne!(color2, color1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_get_color_encrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = create_group_ex(t, Some(ProtectionStatus::Unprotected), "a chat").await?;
let color1 = Chat::load_from_db(t, chat_id).await?.get_color(t).await?;
set_chat_name(t, chat_id, "A CHAT").await?;
let color2 = Chat::load_from_db(t, chat_id).await?.get_color(t).await?;
assert_eq!(color2, color1);
Ok(())
}
async fn test_sticker(
filename: &str,
bytes: &[u8],
@@ -2262,7 +2279,8 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> {
let group_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "group2").await?;
add_contact_to_chat(&bob, group_id, charlie_id).await?;
let broadcast_id = create_broadcast(&bob, "Channel".to_string()).await?;
add_contact_to_chat(&bob, broadcast_id, charlie_id).await?;
let qr = get_securejoin_qr(&bob, Some(broadcast_id)).await?;
tcm.exec_securejoin_qr(&charlie, &bob, &qr).await;
for chat_id in &[single_id, group_id, broadcast_id] {
forward_msgs(&bob, &[orig_msg.id], *chat_id).await?;
let sent_msg = bob.pop_sent_msg().await;
@@ -2280,14 +2298,19 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice_chat = alice.create_chat(&bob).await;
let sent = alice.send_text(alice_chat.get_id(), "hi, bob").await;
let sent_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
assert!(sent_msg.get_saved_msg_id(&alice).await?.is_none());
assert!(sent_msg.get_original_msg_id(&alice).await?.is_none());
let sent_timestamp = sent_msg.get_timestamp();
assert!(sent_timestamp > 0);
SystemTime::shift(Duration::from_secs(60));
let self_chat = alice.get_self_chat().await;
save_msgs(&alice, &[sent.sender_msg_id]).await?;
@@ -2305,6 +2328,8 @@ async fn test_save_msgs() -> Result<()> {
assert_eq!(saved_msg.get_from_id(), ContactId::SELF);
assert_eq!(saved_msg.get_state(), MessageState::OutDelivered);
assert_ne!(saved_msg.rfc724_mid(), sent_msg.rfc724_mid());
let saved_timestamp = saved_msg.get_timestamp();
assert_eq!(saved_timestamp, sent_timestamp);
let sent_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
assert_eq!(
@@ -2618,44 +2643,67 @@ async fn test_can_send_group() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast() -> Result<()> {
async fn test_broadcast_change_name() -> Result<()> {
// create two context, send two messages so both know the other
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let fiona = TestContext::new_fiona().await;
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let chat_alice = alice.create_chat(&bob).await;
send_text_msg(&alice, chat_alice.id, "hi!".to_string()).await?;
tcm.section("Alice sends a message to Bob");
let chat_alice = alice.create_chat(bob).await;
send_text_msg(alice, chat_alice.id, "hi!".to_string()).await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
let chat_bob = bob.create_chat(&alice).await;
send_text_msg(&bob, chat_bob.id, "ho!".to_string()).await?;
tcm.section("Bob sends a message to Alice");
let chat_bob = bob.create_chat(alice).await;
send_text_msg(bob, chat_bob.id, "ho!".to_string()).await?;
let msg = alice.recv_msg(&bob.pop_sent_msg().await).await;
assert!(msg.get_showpadlock());
// test broadcast channel
let broadcast_id = create_broadcast(&alice, "Channel".to_string()).await?;
add_contact_to_chat(
&alice,
broadcast_id,
get_chat_contacts(&alice, chat_bob.id).await?.pop().unwrap(),
)
.await?;
let fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await;
add_contact_to_chat(&alice, broadcast_id, fiona_contact_id).await?;
set_chat_name(&alice, broadcast_id, "Broadcast channel").await?;
let broadcast_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap();
tcm.section("Alice invites Bob to her channel");
tcm.exec_securejoin_qr(bob, alice, &qr).await;
tcm.section("Alice invites Fiona to her channel");
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
{
let chat = Chat::load_from_db(&alice, broadcast_id).await?;
tcm.section("Alice changes the chat name");
set_chat_name(alice, broadcast_id, "My great broadcast").await?;
let sent = alice.pop_sent_msg().await;
tcm.section("Bob receives the name-change system message");
let msg = bob.recv_msg(&sent).await;
assert_eq!(msg.subject, "Re: My great broadcast");
let bob_chat = Chat::load_from_db(bob, msg.chat_id).await?;
assert_eq!(bob_chat.name, "My great broadcast");
tcm.section("Fiona receives the name-change system message");
let msg = fiona.recv_msg(&sent).await;
assert_eq!(msg.subject, "Re: My great broadcast");
let fiona_chat = Chat::load_from_db(fiona, msg.chat_id).await?;
assert_eq!(fiona_chat.name, "My great broadcast");
}
{
tcm.section("Alice changes the chat name again, but the system message is lost somehow");
set_chat_name(alice, broadcast_id, "Broadcast channel").await?;
let chat = Chat::load_from_db(alice, broadcast_id).await?;
assert_eq!(chat.typ, Chattype::OutBroadcast);
assert_eq!(chat.name, "Broadcast channel");
assert!(!chat.is_self_talk());
send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?;
tcm.section("Alice sends a text message 'ola!'");
send_text_msg(alice, broadcast_id, "ola!".to_string()).await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.chat_id, chat.id);
}
{
tcm.section("Bob receives the 'ola!' message");
let sent_msg = alice.pop_sent_msg().await;
let msg = bob.parse_msg(&sent_msg).await;
assert!(msg.was_encrypted());
@@ -2668,25 +2716,23 @@ async fn test_broadcast() -> Result<()> {
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_text(), "ola!");
assert_eq!(msg.subject, "Broadcast channel");
assert_eq!(msg.subject, "Re: Broadcast channel");
assert!(msg.get_showpadlock());
assert!(msg.get_override_sender_name().is_none());
let chat = Chat::load_from_db(&bob, msg.chat_id).await?;
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
assert_eq!(chat.typ, Chattype::InBroadcast);
assert_ne!(chat.id, chat_bob.id);
assert_eq!(chat.name, "Broadcast channel");
assert!(!chat.is_self_talk());
}
{
// Alice changes the name:
set_chat_name(&alice, broadcast_id, "My great broadcast").await?;
let sent = alice.send_text(broadcast_id, "I changed the title!").await;
let msg = bob.recv_msg(&sent).await;
assert_eq!(msg.subject, "Re: My great broadcast");
let bob_chat = Chat::load_from_db(&bob, msg.chat_id).await?;
assert_eq!(bob_chat.name, "My great broadcast");
tcm.section("Fiona receives the 'ola!' message");
let msg = fiona.recv_msg(&sent_msg).await;
assert_eq!(msg.get_text(), "ola!");
assert!(msg.get_showpadlock());
assert!(msg.get_override_sender_name().is_none());
let chat = Chat::load_from_db(fiona, msg.chat_id).await?;
assert_eq!(chat.typ, Chattype::InBroadcast);
assert_eq!(chat.name, "Broadcast channel");
}
Ok(())
@@ -2702,45 +2748,43 @@ async fn test_broadcast() -> Result<()> {
/// `test_sync_broadcast()` tests that synchronization works via sync messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_multidev() -> Result<()> {
let alices = [
TestContext::new_alice().await,
TestContext::new_alice().await,
];
let bob = TestContext::new_bob().await;
let a1b_contact_id = alices[1].add_or_lookup_contact(&bob).await.id;
let mut tcm = TestContextManager::new();
let alice0 = &tcm.alice().await;
let alice1 = &tcm.alice().await;
for a in &[alice0, alice1] {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
let bob = &tcm.bob().await;
let a0_broadcast_id = create_broadcast(&alices[0], "Channel".to_string()).await?;
let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?;
set_chat_name(&alices[0], a0_broadcast_id, "Broadcast channel 42").await?;
let sent_msg = alices[0].send_text(a0_broadcast_id, "hi").await;
let msg = alices[1].recv_msg(&sent_msg).await;
let a1_broadcast_id = get_chat_id_by_grpid(&alices[1], &a0_broadcast_chat.grpid)
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
sync(alice0, alice1).await;
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?;
let sent_msg = alice0.send_text(a0_broadcast_id, "hi").await;
let msg = alice1.recv_msg(&sent_msg).await;
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
.await?
.unwrap()
.0;
assert_eq!(msg.chat_id, a1_broadcast_id);
let a1_broadcast_chat = Chat::load_from_db(&alices[1], a1_broadcast_id).await?;
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42");
assert!(
get_chat_contacts(&alices[1], a1_broadcast_id)
.await?
.is_empty()
);
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
add_contact_to_chat(&alices[1], a1_broadcast_id, a1b_contact_id).await?;
set_chat_name(&alices[1], a1_broadcast_id, "Broadcast channel 43").await?;
let sent_msg = alices[1].send_text(a1_broadcast_id, "hi").await;
let msg = alices[0].recv_msg(&sent_msg).await;
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
.await
.unwrap();
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
set_chat_name(alice1, a1_broadcast_id, "Broadcast channel 43").await?;
let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await;
let msg = alice0.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, a0_broadcast_id);
let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?;
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
assert_eq!(a0_broadcast_chat.get_type(), Chattype::OutBroadcast);
assert_eq!(a0_broadcast_chat.get_name(), "Broadcast channel 42");
assert!(
get_chat_contacts(&alices[0], a0_broadcast_id)
.await?
.is_empty()
);
assert!(get_chat_contacts(alice0, a0_broadcast_id).await?.is_empty());
Ok(())
}
@@ -2758,7 +2802,6 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
let alice = &tcm.alice().await;
alice.set_config(Config::Displayname, Some("Alice")).await?;
let bob = &tcm.bob().await;
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
tcm.section("Create a broadcast channel");
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
@@ -2766,14 +2809,15 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
assert_eq!(alice_chat.typ, Chattype::OutBroadcast);
let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?;
assert_eq!(alice_chat.is_promoted(), false);
assert_eq!(alice_chat.is_promoted(), true); // Broadcast channels are never unpromoted
let sent = alice.send_text(alice_chat_id, "Hi nobody").await;
let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?;
assert_eq!(alice_chat.is_promoted(), true);
assert_eq!(sent.recipients, "alice@example.org");
tcm.section("Add a contact to the chat and send a message");
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let sent = alice.send_text(alice_chat_id, "Hi somebody").await;
assert_eq!(sent.recipients, "bob@example.net alice@example.org");
@@ -2831,6 +2875,67 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
Ok(())
}
/// Tests that directly after broadcast-securejoin,
/// the brodacast is shown correctly on both devices.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_joining_golden() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
alice.set_config(Config::Displayname, Some("Alice")).await?;
tcm.section("Create a broadcast channel with an avatar");
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
let file = alice.get_blobdir().join("avatar.png");
tokio::fs::write(&file, AVATAR_64x64_BYTES).await?;
set_chat_profile_image(alice, alice_chat_id, file.to_str().unwrap()).await?;
// Because broadcasts are always 'promoted',
// set_chat_profile_image() sends out a message,
// which we need to pop:
alice.pop_sent_msg().await;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
alice
.golden_test_chat(alice_chat_id, "test_broadcast_joining_golden_alice")
.await;
bob.golden_test_chat(bob_chat_id, "test_broadcast_joining_golden_bob")
.await;
let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await;
let direct_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
.await?
.unwrap();
// The 1:1 chat with Bob should not be visible to the user:
assert_eq!(direct_chat.blocked, Blocked::Yes);
alice
.golden_test_chat(direct_chat.id, "test_broadcast_joining_golden_alice_direct")
.await;
assert_eq!(
alice_bob_contact
.get_verifier_id(alice)
.await?
.unwrap()
.unwrap(),
ContactId::SELF
);
let bob_alice_contact = bob.add_or_lookup_contact_no_key(alice).await;
assert_eq!(
bob_alice_contact
.get_verifier_id(bob)
.await?
.unwrap()
.unwrap(),
ContactId::SELF
);
Ok(())
}
/// - Create a broadcast channel
/// - Block it
/// - Check that the broadcast channel appears in the list of blocked contacts
@@ -2842,11 +2947,13 @@ async fn test_block_broadcast() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
tcm.section("Create a broadcast channel with Bob, and send a message");
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let sent = alice.send_text(alice_chat_id, "Hi somebody").await;
let rcvd = bob.recv_msg(&sent).await;
@@ -2854,7 +2961,7 @@ async fn test_block_broadcast() -> Result<()> {
assert_eq!(chats.len(), 1);
assert_eq!(chats.get_chat_id(0)?, rcvd.chat_id);
assert_eq!(rcvd.chat_blocked, Blocked::Request);
assert_eq!(rcvd.chat_blocked, Blocked::Not);
let blocked = Contact::get_all_blocked(bob).await.unwrap();
assert_eq!(blocked.len(), 0);
@@ -2908,11 +3015,13 @@ async fn test_broadcast_channel_protected_listid() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
tcm.section("Create a broadcast channel with Bob, and send a message");
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let mut sent = alice.send_text(alice_chat_id, "Hi somebody").await;
assert!(!sent.payload.contains("List-ID"));
@@ -2957,8 +3066,8 @@ async fn test_leave_broadcast() -> Result<()> {
tcm.section("Alice creates broadcast channel with Bob.");
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
let bob_contact = alice.add_or_lookup_contact(bob).await.id;
add_contact_to_chat(alice, alice_chat_id, bob_contact).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
tcm.section("Alice sends first message to broadcast.");
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
@@ -2981,7 +3090,12 @@ async fn test_leave_broadcast() -> Result<()> {
let leave_msg = bob.pop_sent_msg().await;
alice.recv_msg_trash(&leave_msg).await;
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 0);
assert!(get_chat_contacts(alice, alice_chat_id).await?.is_empty());
assert!(
get_past_chat_contacts(alice, alice_chat_id)
.await?
.is_empty()
);
alice.emit_event(EventType::Test);
alice
@@ -3012,11 +3126,35 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
let alice = &tcm.alice().await;
let bob0 = &tcm.bob().await;
let bob1 = &tcm.bob().await;
for b in [bob0, bob1] {
b.set_config_bool(Config::SyncMsgs, true).await?;
}
tcm.section("Alice creates broadcast channel with Bob.");
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
let bob_contact = alice.add_or_lookup_contact(bob0).await.id;
add_contact_to_chat(alice, alice_chat_id, bob_contact).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
join_securejoin(bob0, &qr).await.unwrap();
let request = bob0.pop_sent_msg().await;
// Bob must send the message only to Alice, not to Self,
// because otherwise, his second device would show a device message
// "⚠️ 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."
assert_eq!(request.recipients, "alice@example.org");
alice.recv_msg_trash(&request).await;
let answer = alice.pop_sent_msg().await;
bob0.recv_msg(&answer).await;
// Sync Bob's verification of Alice:
sync(bob0, bob1).await;
bob1.recv_msg(&answer).await;
// The 1:1 chat should not be visible to the user on any of the devices.
// The contact should be marked as verified.
check_direct_chat_is_hidden_and_contact_is_verified(alice, bob0).await;
check_direct_chat_is_hidden_and_contact_is_verified(bob0, alice).await;
check_direct_chat_is_hidden_and_contact_is_verified(bob1, alice).await;
tcm.section("Alice sends first message to broadcast.");
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
@@ -3048,6 +3186,180 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
Ok(())
}
async fn check_direct_chat_is_hidden_and_contact_is_verified(
t: &TestContext,
contact: &TestContext,
) {
let contact = t.add_or_lookup_contact_no_key(contact).await;
if let Some(direct_chat) = ChatIdBlocked::lookup_by_contact(t, contact.id)
.await
.unwrap()
{
assert_eq!(direct_chat.blocked, Blocked::Yes);
}
assert!(contact.is_verified(t).await.unwrap());
}
/// Test that only the owner of the broadcast channel
/// can send messages into the chat.
///
/// To do so, we change Alice's public key on Bob's side,
/// so that she is supposed to appear as a new contact when we receive another message,
/// and check that she can't write into the channel.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_only_broadcast_owner_can_send_1() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.section("Alice creates broadcast channel and creates a QR code.");
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.section("Bob now scans the QR code sends the request message");
let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap();
let request = bob.pop_sent_msg().await;
alice.recv_msg_trash(&request).await;
tcm.section("Alice answers");
let answer = alice.pop_sent_msg().await;
tcm.section("Change Alice's fingerprint for Bob, so that she is a different contact from Bob's point of view");
let bob_alice_id = bob.add_or_lookup_contact_no_key(alice).await.id;
bob.sql
.execute(
"UPDATE contacts
SET fingerprint='1234567890123456789012345678901234567890'
WHERE id=?",
(bob_alice_id,),
)
.await?;
tcm.section("Bob receives an answer, but it ignored because of a fingerprint mismatch");
bob.recv_msg(&answer).await;
assert!(
load_broadcast_shared_secret(bob, bob_broadcast_id)
.await?
.is_none()
);
Ok(())
}
/// Same as the previous test, but Alice's fingerprint is changed later,
/// so that we can check that until the fingerprint change, everything works fine.
///
/// Also, this changes Alice's fingerprint in Alice's database, rather than Bob's database,
/// in order to test for the same thing in different ways.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &mut tcm.bob().await;
tcm.section("Alice creates broadcast channel and creates a QR code.");
let alice_broadcast_id = create_broadcast(alice, "foo".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_broadcast_id))
.await
.unwrap();
tcm.section("Bob now scans the QR code");
let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap();
let request = bob.pop_sent_msg().await;
alice.recv_msg_trash(&request).await;
let answer = alice.pop_sent_msg().await;
tcm.section("Bob receives an answer, and processes it");
let rcvd = bob.recv_msg(&answer).await;
assert!(
load_broadcast_shared_secret(bob, bob_broadcast_id)
.await?
.is_some()
);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
tcm.section("Alice sends a message, which still arrives fine");
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.text, "Hi");
tcm.section("Now, Alice's fingerprint changes");
alice.sql.execute("DELETE FROM keypairs", ()).await?;
alice
.sql
.execute("DELETE FROM config WHERE keyname='key_id'", ())
.await?;
// Invalidate cached self fingerprint:
Arc::get_mut(&mut bob.ctx.inner)
.unwrap()
.self_fingerprint
.take();
tcm.section("Alice sends a message, which doesn't arrive fine");
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(
rcvd.text,
"[Error: This message was not sent by the channel owner]"
);
assert_eq!(
rcvd.error.unwrap(),
r#"Error: This message was not sent by the channel owner:
"Hi""#
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_encrypt_decrypt_broadcast() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_without_secret = &tcm.bob().await;
let secret = "secret";
let grpid = "grpid";
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
tcm.section("Create a broadcast channel with Bob, and send a message");
let alice_chat_id = create_out_broadcast_ex(
alice,
Sync,
"My Channel".to_string(),
grpid.to_string(),
secret.to_string(),
)
.await?;
add_to_chat_contacts_table(alice, time(), alice_chat_id, &[alice_bob_contact_id]).await?;
let bob_chat_id = ChatId::create_multiuser_record(
bob,
Chattype::InBroadcast,
grpid,
"My Channel",
Blocked::Not,
ProtectionStatus::Unprotected,
None,
time(),
)
.await?;
save_broadcast_shared_secret(bob, bob_chat_id, secret).await?;
let sent = alice
.send_text(alice_chat_id, "Symmetrically encrypted message")
.await;
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.text, "Symmetrically encrypted message");
tcm.section("If Bob doesn't know the secret, he can't decrypt the message");
bob_without_secret.recv_msg_trash(&sent).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_for_contact_with_blocked() -> Result<()> {
let t = TestContext::new().await;
@@ -3152,6 +3464,30 @@ async fn test_chat_get_encryption_info() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_out_failed_on_all_keys_missing() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let bob_chat_id = bob
.create_group_with_members(ProtectionStatus::Unprotected, "", &[alice, fiona])
.await;
bob.send_text(bob_chat_id, "Gossiping Fiona's key").await;
alice
.recv_msg(&bob.send_text(bob_chat_id, "No key gossip").await)
.await;
SystemTime::shift(Duration::from_secs(60));
remove_contact_from_chat(bob, bob_chat_id, ContactId::SELF).await?;
let alice_chat_id = alice.recv_msg(&bob.pop_sent_msg().await).await.chat_id;
alice_chat_id.accept(alice).await?;
let mut msg = Message::new_text("Hi".to_string());
send_msg(alice, alice_chat_id, &mut msg).await.ok();
assert_eq!(msg.id.get_state(alice).await?, MessageState::OutFailed);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_chat_media() -> Result<()> {
let t = TestContext::new_alice().await;
@@ -3738,55 +4074,86 @@ async fn test_sync_muted() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync_broadcast() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice0 = &tcm.alice().await;
let alice1 = &tcm.alice().await;
for a in [alice0, alice1] {
let alice2 = &tcm.alice().await;
for a in [alice1, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
let bob = &tcm.bob().await;
let a0b_contact_id = alice0.add_or_lookup_contact(bob).await.id;
let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id;
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
sync(alice0, alice1).await;
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
tcm.section("Alice creates a channel on her first device");
let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?;
tcm.section("The channel syncs to her second device");
sync(alice1, alice2).await;
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
let a2_broadcast_id = get_chat_id_by_grpid(alice2, &a1_broadcast_chat.grpid)
.await?
.unwrap()
.0;
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
assert_eq!(a1_broadcast_chat.get_name(), a0_broadcast_chat.get_name());
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
add_contact_to_chat(alice0, a0_broadcast_id, a0b_contact_id).await?;
sync(alice0, alice1).await;
let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?;
assert_eq!(a2_broadcast_chat.get_type(), Chattype::OutBroadcast);
assert_eq!(a2_broadcast_chat.get_name(), a1_broadcast_chat.get_name());
assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty());
// This also imports Bob's key from the vCard.
// Otherwise it is possible that second device
// does not have Bob's key as only the fingerprint
// is transferred in the sync message.
let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id;
tcm.section("Bob scans Alice's QR code, both of Alice's devices answer");
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
.await
.unwrap();
sync(alice1, alice2).await; // Sync QR code
let bob_broadcast_id = tcm
.exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr)
.await;
let a2b_contact_id = alice2.add_or_lookup_contact_no_key(bob).await.id;
assert_eq!(
get_chat_contacts(alice1, a1_broadcast_id).await?,
vec![a1b_contact_id]
get_chat_contacts(alice2, a2_broadcast_id).await?,
vec![a2b_contact_id]
);
let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await;
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;
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
assert_eq!(chat.get_type(), Chattype::InBroadcast);
let msg = alice0.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, a0_broadcast_id);
remove_contact_from_chat(alice0, a0_broadcast_id, a0b_contact_id).await?;
sync(alice0, alice1).await;
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
let msg = alice1.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, a1_broadcast_id);
tcm.section("Alice's first device removes Bob");
remove_contact_from_chat(alice1, a1_broadcast_id, a1b_contact_id).await?;
let sent = alice1.pop_sent_msg().await;
tcm.section("Alice's second device receives the removal-message");
alice2.recv_msg(&sent).await;
assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty());
assert!(
get_past_chat_contacts(alice1, a1_broadcast_id)
get_past_chat_contacts(alice2, a2_broadcast_id)
.await?
.is_empty()
);
a0_broadcast_id.delete(alice0).await?;
sync(alice0, alice1).await;
alice1.assert_no_chat(a1_broadcast_id).await;
tcm.section("Bob receives the removal-message");
bob.recv_msg(&sent).await;
let bob_chat = Chat::load_from_db(bob, bob_broadcast_id).await?;
assert!(!bob_chat.is_self_in_chat(bob).await?);
bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob")
.await;
// Alice1 and Alice2 are supposed to show the chat in the same way:
alice1
.golden_test_chat(a1_broadcast_id, "test_sync_broadcast_alice1")
.await;
alice2
.golden_test_chat(a2_broadcast_id, "test_sync_broadcast_alice2")
.await;
tcm.section("Alice's first device deletes the chat");
a1_broadcast_id.delete(alice1).await?;
sync(alice1, alice2).await;
alice2.assert_no_chat(a2_broadcast_id).await;
Ok(())
}
@@ -3800,12 +4167,25 @@ async fn test_sync_name() -> Result<()> {
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
sync(alice0, alice1).await;
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?;
sync(alice0, alice1).await;
//sync(alice0, alice1).await; // crash
let sent = alice0.pop_sent_msg().await;
let rcvd = alice1.recv_msg(&sent).await;
assert_eq!(rcvd.from_id, ContactId::SELF);
assert_eq!(rcvd.to_id, ContactId::SELF);
assert_eq!(
rcvd.text,
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
.await?
.unwrap()
.0;
assert_eq!(rcvd.chat_id, a1_broadcast_id);
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42");
@@ -4742,6 +5122,16 @@ async fn test_create_unencrypted_group_chat() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_group_invalid_name() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = create_group_ex(alice, None, " ").await?;
let chat = Chat::load_from_db(alice, chat_id).await?;
assert_eq!(chat.get_name(), "");
Ok(())
}
/// Tests that avatar cannot be set in ad hoc groups.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_avatar_in_adhoc_chats() -> Result<()> {
@@ -4772,3 +5162,25 @@ async fn test_no_avatar_in_adhoc_chats() -> Result<()> {
Ok(())
}
/// Tests that long group name with non-ASCII characters is correctly received
/// by other members.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_long_group_name() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let group_name = "δδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδ";
let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, group_name).await?;
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
let sent = alice
.send_text(alice_chat_id, "Hi! I created a group.")
.await;
let bob_chat_id = bob.recv_msg(&sent).await.chat_id;
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.name, group_name);
Ok(())
}

View File

@@ -245,9 +245,6 @@ impl Chatlist {
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
};
// Return ProtectionBroken chats also, as that may happen to a verified chat at any
// time. It may be confusing if a chat that is normally in the list disappears
// suddenly. The UI need to deal with that case anyway.
context.sql.query_map(
"SELECT c.id, c.type, c.param, m.id
FROM chats c
@@ -491,6 +488,8 @@ mod tests {
use crate::stock_str::StockMessage;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
use crate::tools::SystemTime;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_try_load() {
@@ -513,6 +512,8 @@ mod tests {
assert_eq!(chats.get_chat_id(1).unwrap(), chat_id2);
assert_eq!(chats.get_chat_id(2).unwrap(), chat_id1);
SystemTime::shift(Duration::from_secs(5));
// New drafts are sorted to the top
// We have to set a draft on the other two messages, too, as
// chat timestamps are only exact to the second and sorting by timestamp

View File

@@ -1,38 +1,39 @@
//! Implementation of Consistent Color Generation.
//! Color generation.
//!
//! Consistent Color Generation is defined in XEP-0392.
//!
//! Color Vision Deficiency correction is not implemented as Delta Chat does not offer
//! corresponding settings.
use hsluv::hsluv_to_rgb;
//! This is similar to Consistent Color Generation defined in XEP-0392,
//! but uses OKLCh colorspace instead of HSLuv
//! to ensure that colors have the same lightness.
use colorutils_rs::{Oklch, Rgb, TransferFunction};
use sha1::{Digest, Sha1};
/// Converts an identifier to Hue angle.
fn str_to_angle(s: &str) -> f64 {
fn str_to_angle(s: &str) -> f32 {
let bytes = s.as_bytes();
let result = Sha1::digest(bytes);
let checksum: u16 = result.first().map_or(0, |&x| u16::from(x))
+ 256 * result.get(1).map_or(0, |&x| u16::from(x));
f64::from(checksum) / 65536.0 * 360.0
f32::from(checksum) / 65536.0 * 360.0
}
/// Converts RGB tuple to a 24-bit number.
///
/// Returns a 24-bit number with 8 least significant bits corresponding to the blue color and 8
/// most significant bits corresponding to the red color.
fn rgb_to_u32((r, g, b): (f64, f64, f64)) -> u32 {
let r = ((r * 256.0) as u32).min(255);
let g = ((g * 256.0) as u32).min(255);
let b = ((b * 256.0) as u32).min(255);
65536 * r + 256 * g + b
fn rgb_to_u32(rgb: Rgb<u8>) -> u32 {
65536 * u32::from(rgb.r) + 256 * u32::from(rgb.g) + u32::from(rgb.b)
}
/// Converts an identifier to RGB color.
///
/// Saturation is set to maximum (100.0) to make colors distinguishable, and lightness is set to
/// half (50.0) to make colors suitable both for light and dark theme.
/// Lightness is set to half (0.5) to make colors suitable both for light and dark theme.
pub fn str_to_color(s: &str) -> u32 {
rgb_to_u32(hsluv_to_rgb((str_to_angle(s), 100.0, 50.0)))
let lightness = 0.5;
let chroma = 0.22;
let angle = str_to_angle(s);
let oklch = Oklch::new(lightness, chroma, angle);
let rgb = oklch.to_rgb(TransferFunction::Srgb);
rgb_to_u32(rgb)
}
/// Returns color as a "#RRGGBB" `String` where R, G, B are hex digits.
@@ -45,6 +46,7 @@ mod tests {
use super::*;
#[test]
#[allow(clippy::excessive_precision)]
fn test_str_to_angle() {
// Test against test vectors from
// <https://xmpp.org/extensions/xep-0392.html#testvectors-fullrange-no-cvd>
@@ -57,11 +59,11 @@ mod tests {
#[test]
fn test_rgb_to_u32() {
assert_eq!(rgb_to_u32((0.0, 0.0, 0.0)), 0);
assert_eq!(rgb_to_u32((1.0, 1.0, 1.0)), 0xffffff);
assert_eq!(rgb_to_u32((0.0, 0.0, 1.0)), 0x0000ff);
assert_eq!(rgb_to_u32((0.0, 1.0, 0.0)), 0x00ff00);
assert_eq!(rgb_to_u32((1.0, 0.0, 0.0)), 0xff0000);
assert_eq!(rgb_to_u32((1.0, 0.5, 0.0)), 0xff8000);
assert_eq!(rgb_to_u32(Rgb::new(0, 0, 0)), 0);
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0xff, 0xff)), 0xffffff);
assert_eq!(rgb_to_u32(Rgb::new(0, 0, 0xff)), 0x0000ff);
assert_eq!(rgb_to_u32(Rgb::new(0, 0xff, 0)), 0x00ff00);
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0, 0)), 0xff0000);
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0x80, 0)), 0xff8000);
}
}

View File

@@ -151,10 +151,6 @@ pub enum Config {
/// setting up a second device, or receiving a sync message.
BccSelf,
/// True if encryption is preferred according to Autocrypt standard.
#[strum(props(default = "1"))]
E2eeEnabled,
/// True if Message Delivery Notifications (read receipts) should
/// be sent and requested.
#[strum(props(default = "1"))]
@@ -417,12 +413,12 @@ pub enum Config {
#[strum(props(default = "172800"))]
GossipPeriod,
/// Feature flag for verified 1:1 chats; the UI should set it
/// Deprecated 2025-07. Feature flag for verified 1:1 chats; the UI should set it
/// to 1 if it supports verified 1:1 chats.
/// Regardless of this setting, `chat.is_protected()` returns true while the key is verified,
/// and when the key changes, an info message is posted into the chat.
/// 0=Nothing else happens when the key changes.
/// 1=After the key changed, `can_send()` returns false and `is_protection_broken()` returns true
/// 1=After the key changed, `can_send()` returns false
/// until `chat_id.accept()` is called.
#[strum(props(default = "0"))]
VerifiedOneOnOneChats,
@@ -705,7 +701,6 @@ impl Context {
Config::Socks5Enabled
| Config::ProxyEnabled
| Config::BccSelf
| Config::E2eeEnabled
| Config::MdnsEnabled
| Config::SentboxWatch
| Config::MvboxMove
@@ -734,7 +729,7 @@ impl Context {
Self::check_config(key, value)?;
let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self.clone()).await?,
true => self.scheduler.pause(self).await?,
_ => Default::default(),
};
self.set_config_internal(key, value).await?;

View File

@@ -95,10 +95,10 @@ pub const DC_GCL_ADDRESS: u32 = 0x04;
pub(crate) const DC_RESEND_USER_AVATAR_DAYS: i64 = 14;
// warn about an outdated app after a given number of days.
// as we use the "provider-db generation date" as reference (that might not be updated very often)
// and as not all system get speedy updates,
// reference is the release date.
// as not all system get speedy updates,
// do not use too small value that will annoy users checking for nonexistent updates.
pub(crate) const DC_OUTDATED_WARNING_DAYS: i64 = 365;
pub(crate) const DC_OUTDATED_WARNING_DAYS: i64 = 183;
/// messages that should be deleted get this chat_id; the messages are deleted from the working thread later then. This is also needed as rfc724_mid should be preset as long as the message is not deleted on the server (otherwise it is downloaded again)
pub const DC_CHAT_ID_TRASH: ChatId = ChatId::new(3);

View File

@@ -21,7 +21,7 @@ use tokio::task;
use tokio::time::{Duration, timeout};
use crate::blob::BlobObject;
use crate::chat::{ChatId, ChatIdBlocked, ProtectionStatus};
use crate::chat::ChatId;
use crate::color::str_to_color;
use crate::config::Config;
use crate::constants::{self, Blocked, Chattype};
@@ -755,7 +755,19 @@ impl Contact {
self.is_bot
}
/// Check if an e-mail address belongs to a known and unblocked contact.
/// Looks up a known and unblocked contact with a given e-mail address.
/// To get a list of all known and unblocked contacts, use contacts_get_contacts().
///
///
/// **POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
/// (e.g. an address-contact and a key-contact),
/// this looks up the most recently seen contact,
/// i.e. which contact is returned depends on which contact last sent a message.
/// If the user just clicked on a mailto: link, then this is the best thing you can do.
/// But **DO NOT** internally represent contacts by their email address
/// and do not use this function to look them up;
/// otherwise this function will sometimes look up the wrong contact.
/// Instead, you should internally represent contacts by their ids.
///
/// Known and unblocked contacts will be returned by `get_contacts()`.
///
@@ -795,14 +807,28 @@ impl Contact {
.query_get_value(
"SELECT id FROM contacts
WHERE addr=?1 COLLATE NOCASE
AND fingerprint='' -- Do not lookup key-contacts
AND id>?2 AND origin>=?3 AND (? OR blocked=?)",
AND id>?2 AND origin>=?3 AND (? OR blocked=?)
ORDER BY
(
SELECT COUNT(*) FROM chats c
INNER JOIN chats_contacts cc
ON c.id=cc.chat_id
WHERE c.type=?
AND c.id>?
AND c.blocked=?
AND cc.contact_id=contacts.id
) DESC,
last_seen DESC, fingerprint DESC
LIMIT 1",
(
&addr_normalized,
ContactId::LAST_SPECIAL,
min_origin as u32,
blocked.is_none(),
blocked.unwrap_or_default(),
blocked.unwrap_or(Blocked::Not),
Chattype::Single,
constants::DC_CHAT_ID_LAST_SPECIAL,
blocked.unwrap_or(Blocked::Not),
),
)
.await?;
@@ -1538,7 +1564,7 @@ impl Contact {
return Ok(Some(chat::get_device_icon(context).await?));
}
if show_fallback_icon && !self.id.is_special() && !self.is_key_contact() {
return Ok(Some(chat::get_address_contact_icon(context).await?));
return Ok(Some(chat::get_unencrypted_icon(context).await?));
}
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
if !image_rel.is_empty() {
@@ -1549,11 +1575,16 @@ impl Contact {
}
/// Get a color for the contact.
/// The color is calculated from the contact's email address
/// and can be used for an fallback avatar with white initials
/// The color is calculated from the contact's fingerprint (for key-contacts)
/// or email address (for address-contacts) and can be used
/// for an fallback avatar with white initials
/// as well as for headlines in bubbles of group chats.
pub fn get_color(&self) -> u32 {
str_to_color(&self.addr.to_lowercase())
if let Some(fingerprint) = self.fingerprint() {
str_to_color(&fingerprint.hex())
} else {
str_to_color(&self.addr.to_lowercase())
}
}
/// Gets the contact's status.
@@ -1619,29 +1650,6 @@ impl Contact {
}
}
/// Returns if the contact profile title should display a green checkmark.
///
/// This generally should be consistent with the 1:1 chat with the contact
/// so 1:1 chat with the contact and the contact profile
/// either both display the green checkmark or both don't display a green checkmark.
///
/// UI often knows beforehand if a chat exists and can also call
/// `chat.is_protected()` (if there is a chat)
/// or `contact.is_verified()` (if there is no chat) directly.
/// This is often easier and also skips some database calls.
pub async fn is_profile_verified(&self, context: &Context) -> Result<bool> {
let contact_id = self.id;
if let Some(ChatIdBlocked { id: chat_id, .. }) =
ChatIdBlocked::lookup_by_contact(context, contact_id).await?
{
Ok(chat_id.is_protected(context).await? == ProtectionStatus::Protected)
} else {
// 1:1 chat does not exist.
Ok(self.is_verified(context).await?)
}
}
/// Returns the number of real (i.e. non-special) contacts in the database.
pub async fn get_real_cnt(context: &Context) -> Result<usize> {
if !context.sql.is_open().await {
@@ -1917,16 +1925,21 @@ pub(crate) async fn update_last_seen(
}
/// Marks contact `contact_id` as verified by `verifier_id`.
///
/// `verifier_id == None` means that the verifier is unknown.
pub(crate) async fn mark_contact_id_as_verified(
context: &Context,
contact_id: ContactId,
verifier_id: ContactId,
verifier_id: Option<ContactId>,
) -> Result<()> {
ensure_and_debug_assert_ne!(contact_id, ContactId::SELF,);
ensure_and_debug_assert_ne!(
contact_id,
Some(contact_id),
verifier_id,
"Contact cannot be verified by self",
);
let by_self = verifier_id == Some(ContactId::SELF);
let mut verifier_id = verifier_id.unwrap_or(contact_id);
context
.sql
.transaction(|transaction| {
@@ -1939,20 +1952,33 @@ pub(crate) async fn mark_contact_id_as_verified(
bail!("Non-key-contact {contact_id} cannot be verified");
}
if verifier_id != ContactId::SELF {
let verifier_fingerprint: String = transaction.query_row(
"SELECT fingerprint FROM contacts WHERE id=?",
(verifier_id,),
|row| row.get(0),
)?;
let (verifier_fingerprint, verifier_verifier_id): (String, ContactId) = transaction
.query_row(
"SELECT fingerprint, verifier FROM contacts WHERE id=?",
(verifier_id,),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
if verifier_fingerprint.is_empty() {
bail!(
"Contact {contact_id} cannot be verified by non-key-contact {verifier_id}"
);
}
ensure!(
verifier_id == contact_id || verifier_verifier_id != ContactId::UNDEFINED,
"Contact {contact_id} cannot be verified by unverified contact {verifier_id}",
);
if verifier_verifier_id == verifier_id {
// Avoid introducing incorrect reverse chains: if the verifier itself has an
// unknown verifier, it may be `contact_id` actually (directly or indirectly) on
// the other device (which is needed for getting "verified by unknown contact"
// in the first place).
verifier_id = contact_id;
}
}
transaction.execute(
"UPDATE contacts SET verifier=? WHERE id=?",
(verifier_id, contact_id),
"UPDATE contacts SET verifier=?1
WHERE id=?2 AND (verifier=0 OR verifier=id OR ?3)",
(verifier_id, contact_id, by_self),
)?;
Ok(())
})

View File

@@ -1,7 +1,7 @@
use deltachat_contact_tools::{addr_cmp, may_be_valid_addr};
use super::*;
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chat::{Chat, ProtectionStatus, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::receive_imf::receive_imf;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
@@ -759,7 +759,7 @@ async fn test_contact_get_color() -> Result<()> {
let t = TestContext::new().await;
let contact_id = Contact::create(&t, "name", "name@example.net").await?;
let color1 = Contact::get_by_id(&t, contact_id).await?.get_color();
assert_eq!(color1, 0xA739FF);
assert_eq!(color1, 0x4947dc);
let t = TestContext::new().await;
let contact_id = Contact::create(&t, "prename name", "name@example.net").await?;
@@ -1035,6 +1035,50 @@ async fn test_was_seen_recently_event() -> Result<()> {
Ok(())
}
async fn test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let raw = include_bytes!("../../test-data/message/thunderbird_with_autocrypt.eml");
assert!(std::str::from_utf8(raw)?.contains("Date: Thu, 24 Nov 2022 20:05:57 +0100"));
let received_msg = receive_imf(bob, raw, false).await?.unwrap();
received_msg.chat_id.accept(bob).await?;
let raw = r#"From: Alice <alice@example.org>
To: bob@example.net
Message-ID: message$TIME@example.org
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Date: Thu, 24 Nov 2022 $TIME +0100
Hi"#
.to_string();
for (time, is_key_contact) in [("20:05:57", true), ("20:05:58", !accept_unencrypted_chat)] {
let raw = raw.replace("$TIME", time);
let received_msg = receive_imf(bob, raw.as_bytes(), false).await?.unwrap();
if accept_unencrypted_chat {
received_msg.chat_id.accept(bob).await?;
}
let contact_id = Contact::lookup_id_by_addr(bob, "alice@example.org", Origin::Unknown)
.await?
.unwrap();
let contact = Contact::get_by_id(bob, contact_id).await?;
assert_eq!(contact.is_key_contact(), is_key_contact);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lookup_id_by_addr_recent() -> Result<()> {
let accept_unencrypted_chat = true;
test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lookup_id_by_addr_recent_accepted() -> Result<()> {
let accept_unencrypted_chat = false;
test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_verified_by_none() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -1072,6 +1116,7 @@ async fn test_sync_create() -> Result<()> {
.unwrap();
let a1b_contact = Contact::get_by_id(alice1, a1b_contact_id).await?;
assert_eq!(a1b_contact.name, "Bob");
assert_eq!(a1b_contact.is_key_contact(), false);
Contact::create(alice0, "Bob Renamed", "bob@example.net").await?;
test_utils::sync(alice0, alice1).await;
@@ -1081,6 +1126,7 @@ async fn test_sync_create() -> Result<()> {
assert_eq!(id, a1b_contact_id);
let a1b_contact = Contact::get_by_id(alice1, a1b_contact_id).await?;
assert_eq!(a1b_contact.name, "Bob Renamed");
assert_eq!(a1b_contact.is_key_contact(), false);
Ok(())
}
@@ -1256,7 +1302,6 @@ async fn test_self_is_verified() -> Result<()> {
let contact = Contact::get_by_id(&alice, ContactId::SELF).await?;
assert_eq!(contact.is_verified(&alice).await?, true);
assert!(contact.is_profile_verified(&alice).await?);
assert!(contact.get_verifier_id(&alice).await?.is_none());
assert!(contact.is_key_contact());

View File

@@ -34,7 +34,7 @@ use crate::param::{Param, Params};
use crate::peer_channels::Iroh;
use crate::push::PushSubscriber;
use crate::quota::QuotaInfo;
use crate::scheduler::{SchedulerState, convert_folder_meaning};
use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning};
use crate::sql::Sql;
use crate::stock_str::StockStrings;
use crate::timesmearing::SmearedTimestamp;
@@ -304,6 +304,10 @@ pub struct InnerContext {
/// tokio::sync::OnceCell would be possible to use, but overkill for our usecase;
/// the standard library's OnceLock is enough, and it's a lot smaller in memory.
pub(crate) self_fingerprint: OnceLock<String>,
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
}
/// The state of ongoing process.
@@ -333,6 +337,15 @@ impl Default for RunningState {
/// about the context on top of the information here.
pub fn get_info() -> BTreeMap<&'static str, String> {
let mut res = BTreeMap::new();
#[cfg(debug_assertions)]
res.insert(
"debug_assertions",
"On - DO NOT RELEASE THIS BUILD".to_string(),
);
#[cfg(not(debug_assertions))]
res.insert("debug_assertions", "Off".to_string());
res.insert("deltachat_core_version", format!("v{}", &*DC_VERSION_STR));
res.insert("sqlite_version", rusqlite::version().to_string());
res.insert("arch", (std::mem::size_of::<usize>() * 8).to_string());
@@ -464,6 +477,7 @@ impl Context {
push_subscribed: AtomicBool::new(false),
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
connectivities: parking_lot::Mutex::new(Vec::new()),
};
let ctx = Context {
@@ -493,7 +507,7 @@ impl Context {
// Now, some configs may have changed, so, we need to invalidate the cache.
self.sql.config_cache.write().await.clear();
self.scheduler.start(self.clone()).await;
self.scheduler.start(self).await;
}
/// Stops the IO scheduler.
@@ -570,7 +584,7 @@ impl Context {
} else {
// Pause the scheduler to ensure another connection does not start
// while we are fetching on a dedicated connection.
let _pause_guard = self.scheduler.pause(self.clone()).await?;
let _pause_guard = self.scheduler.pause(self).await?;
// Start a new dedicated connection.
let mut connection = Imap::new_configured(self, channel::bounded(1).1).await?;
@@ -819,7 +833,6 @@ impl Context {
.query_get_value("PRAGMA journal_mode;", ())
.await?
.unwrap_or_else(|| "unknown".to_string());
let e2ee_enabled = self.get_config_int(Config::E2eeEnabled).await?;
let mdns_enabled = self.get_config_int(Config::MdnsEnabled).await?;
let bcc_self = self.get_config_int(Config::BccSelf).await?;
let sync_msgs = self.get_config_int(Config::SyncMsgs).await?;
@@ -953,7 +966,6 @@ impl Context {
res.insert("configured_mvbox_folder", configured_mvbox_folder);
res.insert("configured_trash_folder", configured_trash_folder);
res.insert("mdns_enabled", mdns_enabled.to_string());
res.insert("e2ee_enabled", e2ee_enabled.to_string());
res.insert("bcc_self", bcc_self.to_string());
res.insert("sync_msgs", sync_msgs.to_string());
res.insert("disable_idle", disable_idle.to_string());
@@ -1043,7 +1055,7 @@ impl Context {
self.get_config_int(Config::GossipPeriod).await?.to_string(),
);
res.insert(
"verified_one_on_one_chats",
"verified_one_on_one_chats", // deprecated 2025-07
self.get_config_bool(Config::VerifiedOneOnOneChats)
.await?
.to_string(),
@@ -1078,7 +1090,6 @@ impl Context {
#[derive(Default)]
struct ChatNumbers {
protected: u32,
protection_broken: u32,
opportunistic_dc: u32,
opportunistic_mua: u32,
unencrypted_dc: u32,
@@ -1114,7 +1125,6 @@ impl Context {
// how many of the chats active in the last months are:
// - protected
// - protection-broken
// - opportunistic-encrypted and the contact uses Delta Chat
// - opportunistic-encrypted and the contact uses a classical MUA
// - unencrypted and the contact uses Delta Chat
@@ -1157,8 +1167,6 @@ impl Context {
if protected == ProtectionStatus::Protected {
chats.protected += 1;
} else if protected == ProtectionStatus::ProtectionBroken {
chats.protection_broken += 1;
} else if encrypted {
if is_dc_message {
chats.opportunistic_dc += 1;
@@ -1176,7 +1184,6 @@ impl Context {
)
.await?;
res += &format!("chats_protected {}\n", chats.protected);
res += &format!("chats_protection_broken {}\n", chats.protection_broken);
res += &format!("chats_opportunistic_dc {}\n", chats.opportunistic_dc);
res += &format!("chats_opportunistic_mua {}\n", chats.opportunistic_mua);
res += &format!("chats_unencrypted_dc {}\n", chats.unencrypted_dc);
@@ -1206,7 +1213,7 @@ impl Context {
.await?
.first()
.context("Self reporting bot vCard does not contain a contact")?;
mark_contact_id_as_verified(self, contact_id, ContactId::SELF).await?;
mark_contact_id_as_verified(self, contact_id, Some(ContactId::SELF)).await?;
let chat_id = ChatId::create_for_contact(self, contact_id).await?;
chat_id

View File

@@ -10,17 +10,19 @@ use crate::pgp;
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
///
/// If successful and the message is encrypted, returns decrypted body.
/// If successful and the message was encrypted,
/// returns the decrypted and decompressed message.
pub fn try_decrypt<'a>(
mail: &'a ParsedMail<'a>,
private_keyring: &'a [SignedSecretKey],
shared_secrets: &[String],
) -> Result<Option<::pgp::composed::Message<'static>>> {
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
return Ok(None);
};
let data = encrypted_data_part.get_body_raw()?;
let msg = pgp::pk_decrypt(data, private_keyring)?;
let msg = pgp::decrypt(data, private_keyring, shared_secrets)?;
Ok(Some(msg))
}

View File

@@ -4,10 +4,8 @@ use std::io::Cursor;
use anyhow::Result;
use mail_builder::mime::MimePart;
use num_traits::FromPrimitive;
use crate::aheader::{Aheader, EncryptPreference};
use crate::config::Config;
use crate::context::Context;
use crate::key::{SignedPublicKey, load_self_public_key, load_self_secret_key};
use crate::pgp;
@@ -21,9 +19,7 @@ pub struct EncryptHelper {
impl EncryptHelper {
pub async fn new(context: &Context) -> Result<EncryptHelper> {
let prefer_encrypt =
EncryptPreference::from_i32(context.get_config_int(Config::E2eeEnabled).await?)
.unwrap_or_default();
let prefer_encrypt = EncryptPreference::Mutual;
let addr = context.get_primary_self_addr().await?;
let public_key = load_self_public_key(context).await?;
@@ -35,9 +31,12 @@ impl EncryptHelper {
}
pub fn get_aheader(&self) -> Aheader {
let pk = self.public_key.clone();
let addr = self.addr.to_string();
Aheader::new(addr, pk, self.prefer_encrypt)
Aheader {
addr: self.addr.clone(),
public_key: self.public_key.clone(),
prefer_encrypt: self.prefer_encrypt,
verified: false,
}
}
/// Tries to encrypt the passed in `mail`.
@@ -59,6 +58,28 @@ impl EncryptHelper {
Ok(ctext)
}
/// Symmetrically encrypt the message to be sent into a broadcast channel,
/// or for version 2 of the Securejoin protocol.
/// `shared secret` is the secret that will be used for symmetric encryption.
pub async fn encrypt_symmetrically(
self,
context: &Context,
shared_secret: &str,
mail_to_encrypt: MimePart<'static>,
compress: bool,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
let mut raw_message = Vec::new();
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();
let ctext =
pgp::symm_encrypt_message(raw_message, shared_secret, sign_key, compress).await?;
Ok(ctext)
}
/// Signs the passed-in `mail` using the private key from `context`.
/// Returns the payload and the signature.
pub async fn sign(self, context: &Context, mail: &MimePart<'static>) -> Result<String> {

View File

@@ -277,6 +277,7 @@ pub(crate) async fn stock_ephemeral_timer_changed(
.await
}
604_800 => stock_str::msg_ephemeral_timer_week(context, from_id).await,
31_536_000..=31_708_800 => stock_str::msg_ephemeral_timer_year(context, from_id).await,
_ => {
stock_str::msg_ephemeral_timer_weeks(
context,

View File

@@ -128,31 +128,33 @@ async fn test_stock_ephemeral_messages() {
/// Test enabling and disabling ephemeral timer remotely.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_enable_disable() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_alice = alice.create_chat(&bob).await.id;
let chat_bob = bob.create_chat(&alice).await.id;
let chat_alice = alice.create_chat(bob).await.id;
let chat_bob = bob.create_chat(alice).await.id;
chat_alice
.set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 })
.set_ephemeral_timer(alice, Timer::Enabled { duration: 60 })
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
let bob_received_message = bob.recv_msg(&sent).await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
bob_received_message.text,
"Message deletion timer is set to 1 minute by alice@example.org."
);
assert_eq!(
chat_bob.get_ephemeral_timer(bob).await?,
Timer::Enabled { duration: 60 }
);
chat_alice
.set_ephemeral_timer(&alice.ctx, Timer::Disabled)
.set_ephemeral_timer(alice, Timer::Disabled)
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Disabled
);
assert_eq!(chat_bob.get_ephemeral_timer(bob).await?, Timer::Disabled);
Ok(())
}

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
use crate::chat::ChatId;
use crate::config::Config;
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::message::MsgId;
@@ -272,9 +273,12 @@ pub enum EventType {
/// ID of the contact that wants to join.
contact_id: ContactId,
/// The type of the joined chat.
chat_type: Chattype,
/// Progress as:
/// 300=vg-/vc-request received, typically shown as "bob@addr joins".
/// 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
/// 600=vg-/vc-request-with-auth received and verified, typically shown as "bob@addr verified".
/// 800=contact added to chat, shown as "bob@addr securely joined GROUP". Only for the verified-group-protocol.
/// 1000=Protocol finished for this contact.
progress: usize,
@@ -376,6 +380,34 @@ pub enum EventType {
/// This event is emitted from the account whose property changed.
AccountsItemChanged,
/// Incoming call.
IncomingCall {
/// ID of the message referring to the call.
msg_id: MsgId,
/// User-defined info as passed to place_outgoing_call()
place_call_info: String,
},
/// Incoming call accepted.
IncomingCallAccepted {
/// ID of the message referring to the call.
msg_id: MsgId,
},
/// Outgoing call accepted.
OutgoingCallAccepted {
/// ID of the message referring to the call.
msg_id: MsgId,
/// User-defined info as passed to accept_incoming_call()
accept_call_info: String,
},
/// Call ended.
CallEnded {
/// ID of the message referring to the call.
msg_id: MsgId,
},
/// Event for using in tests, e.g. as a fence between normally generated events.
#[cfg(test)]
Test,

View File

@@ -63,6 +63,7 @@ pub enum HeaderDef {
ChatUserAvatar,
ChatVoiceMessage,
ChatGroupMemberRemoved,
ChatGroupMemberRemovedFpr,
ChatGroupMemberAdded,
ChatContent,
@@ -86,6 +87,7 @@ pub enum HeaderDef {
ChatDispositionNotificationTo,
ChatWebrtcRoom,
ChatWebrtcAccepted,
/// This message deletes the messages listed in the value by rfc724_mid.
ChatDelete,
@@ -93,6 +95,11 @@ pub enum HeaderDef {
/// This message obsoletes the text of the message defined here by rfc724_mid.
ChatEdit,
/// The secret shared amongst all recipients of this broadcast channel,
/// used to encrypt and decrypt messages.
/// This secret is sent to a new member in the member-addition message.
ChatBroadcastSecret,
/// [Autocrypt](https://autocrypt.org/) header.
Autocrypt,
AutocryptGossip,

View File

@@ -17,7 +17,7 @@ use anyhow::{Context as _, Result, bail, ensure, format_err};
use async_channel::{self, Receiver, Sender};
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use deltachat_contact_tools::ContactAddress;
use futures::{FutureExt as _, StreamExt, TryStreamExt};
use futures::{FutureExt as _, TryStreamExt};
use futures_lite::FutureExt;
use num_traits::FromPrimitive;
use rand::Rng;
@@ -325,7 +325,7 @@ impl Imap {
}
info!(context, "Connecting to IMAP server.");
self.connectivity.set_connecting(context).await;
self.connectivity.set_connecting(context);
self.conn_last_try = tools::Time::now();
const BACKOFF_MIN_MS: u64 = 2000;
@@ -408,7 +408,7 @@ impl Imap {
"IMAP-LOGIN as {}",
lp.user
)));
self.connectivity.set_preparing(context).await;
self.connectivity.set_preparing(context);
info!(context, "Successfully logged into IMAP server.");
return Ok(session);
}
@@ -466,7 +466,7 @@ impl Imap {
let mut session = match self.connect(context, configuring).await {
Ok(session) => session,
Err(err) => {
self.connectivity.set_err(context, &err).await;
self.connectivity.set_err(context, &err);
return Err(err);
}
};
@@ -692,7 +692,7 @@ impl Imap {
}
if !uids_fetch.is_empty() {
self.connectivity.set_working(context).await;
self.connectivity.set_working(context);
}
let (sender, receiver) = async_channel::unbounded();
@@ -1698,7 +1698,7 @@ impl Session {
.uid_store(uid_set, &query)
.await
.with_context(|| format!("IMAP failed to store: ({uid_set}, {query})"))?;
while let Some(_response) = responses.next().await {
while let Some(_response) = responses.try_next().await? {
// Read all the responses
}
Ok(())

View File

@@ -90,7 +90,7 @@ pub async fn imex(
let cancel = context.alloc_ongoing().await?;
let res = {
let _guard = context.scheduler.pause(context.clone()).await?;
let _guard = context.scheduler.pause(context).await?;
imex_inner(context, what, path, passphrase)
.race(async {
cancel.recv().await.ok();
@@ -140,32 +140,8 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
}
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
// try hard to only modify key-state
let (private_key, header) = SignedSecretKey::from_asc(armored)?;
let private_key = SignedSecretKey::from_asc(armored)?;
let public_key = private_key.split_public_key()?;
if let Some(preferencrypt) = header.get("Autocrypt-Prefer-Encrypt") {
let e2ee_enabled = match preferencrypt.as_str() {
"nopreference" => 0,
"mutual" => 1,
_ => {
bail!("invalid Autocrypt-Prefer-Encrypt header: {:?}", header);
}
};
context
.sql
.set_raw_config_int("e2ee_enabled", e2ee_enabled)
.await?;
} else {
// `Autocrypt-Prefer-Encrypt` is not included
// in keys exported to file.
//
// `Autocrypt-Prefer-Encrypt` also SHOULD be sent
// in Autocrypt Setup Message according to Autocrypt specification,
// but K-9 6.802 does not include this header.
//
// We keep current setting in this case.
info!(context, "No Autocrypt-Prefer-Encrypt header.");
};
let keypair = pgp::KeyPair {
public: public_key,
@@ -804,7 +780,7 @@ async fn export_database(
"UPDATE backup.config SET value='0' WHERE keyname='verified_one_on_one_chats';",
[],
)
.ok(); // If verified_one_on_one_chats was not set, this errors, which we ignore
.ok(); // Deprecated 2025-07. If verified_one_on_one_chats was not set, this errors, which we ignore
conn.execute("DETACH DATABASE backup", [])
.context("failed to detach backup database")?;
res?;

View File

@@ -93,12 +93,9 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<St
bail!("Passphrase must be at least 2 chars long.");
};
let private_key = load_self_secret_key(context).await?;
let ac_headers = match context.get_config_bool(Config::E2eeEnabled).await? {
false => None,
true => Some(("Autocrypt-Prefer-Encrypt", "mutual")),
};
let ac_headers = Some(("Autocrypt-Prefer-Encrypt", "mutual"));
let private_key_asc = private_key.to_asc(ac_headers);
let encr = pgp::symm_encrypt(passphrase, private_key_asc.into_bytes())
let encr = pgp::symm_encrypt_setup_file(passphrase, private_key_asc.into_bytes())
.await?
.replace('\n', "\r\n");

View File

@@ -105,7 +105,7 @@ impl BackupProvider {
// Acquire global "ongoing" mutex.
let cancel_token = context.alloc_ongoing().await?;
let paused_guard = context.scheduler.pause(context.clone()).await?;
let paused_guard = context.scheduler.pause(context).await?;
let context_dir = context
.get_blobdir()
.parent()
@@ -250,7 +250,7 @@ impl BackupProvider {
Err(format_err!("Backup provider dropped"))
}
).await {
warn!(context, "Error while handling backup connection: {err:#}.");
error!(context, "Error while handling backup connection: {err:#}.");
context.emit_event(EventType::ImexProgress(0));
break;
} else {
@@ -367,7 +367,8 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
Err(format_err!("Backup reception cancelled"))
})
.await;
if res.is_err() {
if let Err(ref res) = res {
error!(context, "{:#}", res);
context.emit_event(EventType::ImexProgress(0));
}
context.free_ongoing().await;

View File

@@ -0,0 +1,42 @@
//! Re-exports of internal functions needed for benchmarks.
#![allow(missing_docs)] // Not necessary to put a doc comment on the pub functions here
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use crate::chat::ChatId;
use crate::context::Context;
use crate::key;
use crate::key::DcKey;
use crate::mimeparser::MimeMessage;
use crate::pgp;
use crate::pgp::KeyPair;
pub fn key_from_asc(data: &str) -> Result<key::SignedSecretKey> {
key::SignedSecretKey::from_asc(data)
}
pub async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> {
key::store_self_keypair(context, keypair).await
}
pub async fn parse_and_get_text(context: &Context, imf_raw: &[u8]) -> Result<String> {
let mime_parser = MimeMessage::from_bytes(context, imf_raw, None).await?;
Ok(mime_parser.parts.into_iter().next().unwrap().msg)
}
pub async fn save_broadcast_shared_secret(
context: &Context,
chat_id: ChatId,
secret: &str,
) -> Result<()> {
crate::chat::save_broadcast_shared_secret(context, chat_id, secret).await
}
pub fn create_dummy_keypair(addr: &str) -> Result<KeyPair> {
pgp::create_keypair(EmailAddress::new(addr)?)
}
pub fn create_broadcast_shared_secret() -> String {
crate::tools::create_broadcast_shared_secret()
}

View File

@@ -71,31 +71,17 @@ pub(crate) trait DcKey: Serialize + Deserializable + Clone {
}
/// Create a key from an ASCII-armored string.
///
/// Returns the key and a map of any headers which might have been set in
/// the ASCII-armored representation.
fn from_asc(data: &str) -> Result<(Self, BTreeMap<String, String>)> {
fn from_asc(data: &str) -> Result<Self> {
let bytes = data.as_bytes();
let res = Self::from_armor_single(Cursor::new(bytes));
let (key, headers) = match res {
let (key, _headers) = match res {
Err(pgp::errors::Error::NoMatchingPacket { .. }) => match Self::is_private() {
true => bail!("No private key packet found"),
false => bail!("No public key packet found"),
},
_ => res.context("rPGP error")?,
};
let headers = headers
.into_iter()
.map(|(key, values)| {
(
key.trim().to_lowercase(),
values
.last()
.map_or_else(String::new, |s| s.trim().to_string()),
)
})
.collect();
Ok((key, headers))
Ok(key)
}
/// Serialise the key as bytes.
@@ -446,7 +432,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
/// to avoid generating the key in tests.
/// Use import/export APIs instead.
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
let secret = SignedSecretKey::from_asc(secret_data)?.0;
let secret = SignedSecretKey::from_asc(secret_data)?;
let public = secret.split_public_key()?;
let keypair = KeyPair { public, secret };
store_self_keypair(context, &keypair).await?;
@@ -532,7 +518,7 @@ mod tests {
#[test]
fn test_from_armored_string() {
let (private_key, _) = SignedSecretKey::from_asc(
let private_key = SignedSecretKey::from_asc(
"-----BEGIN PGP PRIVATE KEY BLOCK-----
xcLYBF0fgz4BCADnRUV52V4xhSsU56ZaAn3+3oG86MZhXy4X8w14WZZDf0VJGeTh
@@ -600,17 +586,13 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
fn test_asc_roundtrip() {
let key = KEYPAIR.public.clone();
let asc = key.to_asc(Some(("spam", "ham")));
let (key2, hdrs) = SignedPublicKey::from_asc(&asc).unwrap();
let key2 = SignedPublicKey::from_asc(&asc).unwrap();
assert_eq!(key, key2);
assert_eq!(hdrs.len(), 1);
assert_eq!(hdrs.get("spam"), Some(&String::from("ham")));
let key = KEYPAIR.secret.clone();
let asc = key.to_asc(Some(("spam", "ham")));
let (key2, hdrs) = SignedSecretKey::from_asc(&asc).unwrap();
let key2 = SignedSecretKey::from_asc(&asc).unwrap();
assert_eq!(key, key2);
assert_eq!(hdrs.len(), 1);
assert_eq!(hdrs.get("spam"), Some(&String::from("ham")));
}
#[test]

View File

@@ -53,6 +53,7 @@ pub use events::*;
mod aheader;
pub mod blob;
pub mod calls;
pub mod chat;
pub mod chatlist;
pub mod config;
@@ -74,7 +75,10 @@ mod mimefactory;
pub mod mimeparser;
pub mod oauth2;
mod param;
#[cfg(not(feature = "internals"))]
mod pgp;
#[cfg(feature = "internals")]
pub mod pgp;
pub mod provider;
pub mod qr;
pub mod qr_code_generator;
@@ -108,6 +112,9 @@ pub mod accounts;
pub mod peer_channels;
pub mod reaction;
#[cfg(feature = "internals")]
pub mod internals_for_benchmarks;
/// If set IMAP/incoming and SMTP/outgoing MIME messages will be printed.
pub const DCC_MIME_DEBUG: &str = "DCC_MIME_DEBUG";

View File

@@ -973,6 +973,8 @@ impl Message {
| SystemMessage::WebxdcStatusUpdate
| SystemMessage::WebxdcInfoMessage
| SystemMessage::IrohNodeAddr
| SystemMessage::CallAccepted
| SystemMessage::CallEnded
| SystemMessage::Unknown => Ok(None),
}
}
@@ -1366,17 +1368,6 @@ impl Message {
Ok(())
}
pub(crate) async fn update_subject(&self, context: &Context) -> Result<()> {
context
.sql
.execute(
"UPDATE msgs SET subject=? WHERE id=?;",
(&self.subject, self.id),
)
.await?;
Ok(())
}
/// Gets the error status of the message.
///
/// A message can have an associated error status if something went wrong when sending or
@@ -1392,6 +1383,19 @@ impl Message {
pub fn error(&self) -> Option<String> {
self.error.clone()
}
/// Returns `true` if this message is a `vb-request-with-auth` SecureJoin message.
pub(crate) fn is_vb_request_with_auth(&self) -> bool {
if self.param.get_cmd() == SystemMessage::SecurejoinMessage {
// CAVE: You can't check in the same way whether the message
// is a `v{g|b}-member-added` message,
// because for these messages,
// `param.get_cmd()` returns `SystemMessage::MemberAddedToGroup`.
self.param.get(Param::Arg) == Some("vb-request-with-auth")
} else {
false
}
}
}
/// State of the message.
@@ -2287,6 +2291,9 @@ pub enum Viewtype {
/// Message is an invitation to a videochat.
VideochatInvitation = 70,
/// Message is an incoming or outgoing call.
Call = 71,
/// Message is an webxdc instance.
Webxdc = 80,
@@ -2310,6 +2317,7 @@ impl Viewtype {
Viewtype::Video => true,
Viewtype::File => true,
Viewtype::VideochatInvitation => false,
Viewtype::Call => false,
Viewtype::Webxdc => true,
Viewtype::Vcard => true,
}

View File

@@ -808,3 +808,22 @@ async fn test_sanitize_filename_message() -> Result<()> {
Ok(())
}
/// Tests that empty file can be sent and received.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_empty_file() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(alice, "myfile", b"", None)?;
chat::send_msg(alice, alice_chat.id, &mut msg).await?;
let sent = alice.pop_sent_msg().await;
let bob_received_msg = bob.recv_msg(&sent).await;
assert_eq!(bob_received_msg.get_filename().unwrap(), "myfile");
assert_eq!(bob_received_msg.get_viewtype(), Viewtype::File);
Ok(())
}

View File

@@ -3,9 +3,11 @@
use std::collections::{BTreeSet, HashSet};
use std::io::Cursor;
use anyhow::{Context as _, Result, bail, ensure};
use anyhow::{Context as _, Result, bail};
use base64::Engine as _;
use data_encoding::BASE32_NOPAD;
use deltachat_contact_tools::sanitize_bidi_characters;
use iroh_gossip::proto::TopicId;
use mail_builder::headers::HeaderType;
use mail_builder::headers::address::{Address, EmailAddress};
use mail_builder::mime::MimePart;
@@ -13,7 +15,7 @@ use tokio::fs;
use crate::aheader::{Aheader, EncryptPreference};
use crate::blob::BlobObject;
use crate::chat::{self, Chat};
use crate::chat::{self, Chat, load_broadcast_shared_secret};
use crate::config::Config;
use crate::constants::ASM_SUBJECT;
use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
@@ -22,14 +24,14 @@ use crate::context::Context;
use crate::e2ee::EncryptHelper;
use crate::ensure_and_debug_assert;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::key::self_fingerprint;
use crate::key::{DcKey, SignedPublicKey};
use crate::headerdef::HeaderDef;
use crate::key::{DcKey, SignedPublicKey, self_fingerprint};
use crate::location;
use crate::log::{info, warn};
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{SystemMessage, is_hidden};
use crate::param::Param;
use crate::peer_channels::create_iroh_header;
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
use crate::simplify::escape_message_footer_marks;
use crate::stock_str;
use crate::tools::{
@@ -140,6 +142,9 @@ pub struct MimeFactory {
/// True if the avatar should be attached.
pub attach_selfavatar: bool,
/// This field is used to sustain the topic id of webxdcs needed for peer channels.
webxdc_topic: Option<TopicId>,
}
/// Result of rendering a message, ready to be submitted to a send job.
@@ -177,7 +182,7 @@ impl MimeFactory {
let now = time();
let chat = Chat::load_from_db(context, msg.chat_id).await?;
let attach_profile_data = Self::should_attach_profile_data(&msg);
let undisclosed_recipients = chat.typ == Chattype::OutBroadcast;
let undisclosed_recipients = should_hide_recipients(&msg, &chat);
let from_addr = context.get_primary_self_addr().await?;
let config_displayname = context
@@ -248,6 +253,10 @@ impl MimeFactory {
let mut missing_key_addresses = BTreeSet::new();
context
.sql
// Sort recipients by `add_timestamp DESC` so that if the group is large and there
// are multiple SMTP messages, a newly added member receives the member addition
// message earlier and has gossiped keys of other members (otherwise the new member
// may receive messages from other members earlier and fail to verify them).
.query_map(
"SELECT
c.authname,
@@ -261,7 +270,8 @@ impl MimeFactory {
LEFT JOIN contacts c ON cc.contact_id=c.id
LEFT JOIN public_keys k ON k.fingerprint=c.fingerprint
WHERE cc.chat_id=?
AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))",
AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))
ORDER BY cc.add_timestamp DESC",
(msg.chat_id, chat.typ == Chattype::Group),
|row| {
let authname: String = row.get(0)?;
@@ -319,7 +329,7 @@ impl MimeFactory {
if let Some(public_key) = public_key_opt {
keys.push((addr.clone(), public_key))
} else if id != ContactId::SELF {
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone());
if is_encrypted {
warn!(context, "Missing key for {addr}");
@@ -340,7 +350,7 @@ impl MimeFactory {
if let Some(public_key) = public_key_opt {
keys.push((addr.clone(), public_key))
} else if id != ContactId::SELF {
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone());
if is_encrypted {
warn!(context, "Missing key for {addr}");
@@ -405,8 +415,24 @@ impl MimeFactory {
req_mdn = true;
}
// If undisclosed_recipients, and this is a member-added/removed message,
// only send to the added/removed member
if undisclosed_recipients
&& matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup
)
{
if let Some(member) = msg.param.get(Param::Arg) {
recipients.retain(|addr| addr == member);
}
}
encryption_keys = if !is_encrypted {
None
} else if should_encrypt_symmetrically(&msg, &chat) {
// Encrypt, but only symmetrically, not with the public keys.
Some(Vec::new())
} else {
if keys.is_empty() && !recipients.is_empty() {
bail!(
@@ -460,7 +486,7 @@ impl MimeFactory {
past_members.len(),
member_timestamps.len(),
);
let webxdc_topic = get_iroh_topic_for_msg(context, msg.id).await?;
let factory = MimeFactory {
from_addr,
from_displayname,
@@ -480,6 +506,7 @@ impl MimeFactory {
last_added_location_id: None,
sync_ids_to_delete: None,
attach_selfavatar,
webxdc_topic,
};
Ok(factory)
}
@@ -527,6 +554,7 @@ impl MimeFactory {
last_added_location_id: None,
sync_ids_to_delete: None,
attach_selfavatar: false,
webxdc_topic: None,
};
Ok(res)
@@ -551,7 +579,13 @@ impl MimeFactory {
// messages are auto-sent unlike usual unencrypted messages.
step == "vg-request-with-auth"
|| step == "vc-request-with-auth"
|| step == "vb-request-with-auth"
// Note that for "vg-member-added" and "vb-member-added",
// get_cmd() returns `MemberAddedToGroup` rather than `SecurejoinMessage`,
// so, it wouldn't actually be necessary to have them in the list here.
// Still, they are here for completeness.
|| step == "vg-member-added"
|| step == "vb-member-added"
|| step == "vc-contact-confirm"
}
}
@@ -794,7 +828,7 @@ impl MimeFactory {
} else if let Loaded::Message { msg, .. } = &self.loaded {
if msg.param.get_cmd() == SystemMessage::SecurejoinMessage {
let step = msg.param.get(Param::Arg).unwrap_or_default();
if step != "vg-request" && step != "vc-request" {
if step != "vg-request" && step != "vc-request" && step != "vb-request-with-auth" {
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
@@ -803,7 +837,7 @@ impl MimeFactory {
}
}
if let Loaded::Message { chat, .. } = &self.loaded {
if let Loaded::Message { msg, chat } = &self.loaded {
if chat.typ == Chattype::OutBroadcast || chat.typ == Chattype::InBroadcast {
headers.push((
"List-ID",
@@ -813,6 +847,15 @@ impl MimeFactory {
))
.into(),
));
if msg.param.get_cmd() == SystemMessage::MemberAddedToGroup {
if let Some(secret) = msg.param.get(Param::Arg3) {
headers.push((
"Chat-Broadcast-Secret",
mail_builder::headers::text::Text::new(secret.to_string()).into(),
));
}
}
}
}
@@ -993,6 +1036,15 @@ impl MimeFactory {
} else {
unprotected_headers.push(header.clone());
}
} else if header_name == "chat-broadcast-secret" {
if is_encrypted {
protected_headers.push(header.clone());
} else {
warn!(
context,
"Message is unnecrypted, not including broadcast secret"
);
}
} else if is_encrypted {
protected_headers.push(header.clone());
@@ -1048,7 +1100,7 @@ impl MimeFactory {
match &self.loaded {
Loaded::Message { chat, msg } => {
if chat.typ != Chattype::OutBroadcast {
if !should_hide_recipients(msg, chat) {
for (addr, key) in &encryption_keys {
let fingerprint = key.dc_fingerprint().hex();
let cmd = msg.param.get_cmd();
@@ -1080,13 +1132,14 @@ impl MimeFactory {
continue;
}
let header = Aheader::new(
addr.clone(),
key.clone(),
let header = Aheader {
addr: addr.clone(),
public_key: key.clone(),
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
EncryptPreference::NoPreference,
)
prefer_encrypt: EncryptPreference::NoPreference,
verified: false,
}
.to_string();
message = message.header(
@@ -1131,18 +1184,48 @@ impl MimeFactory {
Loaded::Mdn { .. } => true,
};
// Encrypt to self unconditionally,
// even for a single-device setup.
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
encryption_keyring.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));
let shared_secret: Option<String> = match &self.loaded {
Loaded::Message { msg, .. } if should_encrypt_with_auth_token(msg) => {
msg.param.get(Param::Arg2).map(|s| s.to_string())
}
Loaded::Message { chat, msg }
if should_encrypt_with_broadcast_secret(msg, chat) =>
{
// If there is no shared secret yet
// (because this is an old broadcast channel,
// created before we had symmetric encryption),
// we just encrypt asymmetrically.
// Symmetric encryption exists since 2025-08;
// some time after that, we can think about requiring everyone
// to switch to symmetrically-encrypted broadcast lists.
load_broadcast_shared_secret(context, chat.id).await?
}
_ => None,
};
let encrypted = if let Some(shared_secret) = shared_secret {
info!(context, "Encrypting symmetrically.");
encrypt_helper
.encrypt_symmetrically(context, &shared_secret, message, compress)
.await?
} else {
// Asymmetric encryption
// Encrypt to self unconditionally,
// even for a single-device setup.
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
encryption_keyring
.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));
encrypt_helper
.encrypt(context, encryption_keyring, message, compress)
.await?
};
// XXX: additional newline is needed
// to pass filtermail at
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>
let encrypted = encrypt_helper
.encrypt(context, encryption_keyring, message, compress)
.await?
+ "\n";
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
let encrypted = encrypted + "\n";
// Set the appropriate Content-Type for the outer message
MimePart::new(
@@ -1351,8 +1434,8 @@ impl MimeFactory {
match command {
SystemMessage::MemberRemovedFromGroup => {
ensure!(chat.typ != Chattype::OutBroadcast);
let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default();
let fingerprint_to_remove = msg.param.get(Param::Arg2).unwrap_or_default();
if email_to_remove
== context
@@ -1373,9 +1456,16 @@ impl MimeFactory {
.into(),
));
}
if !fingerprint_to_remove.is_empty() {
headers.push((
"Chat-Group-Member-Removed-Fpr",
mail_builder::headers::raw::Raw::new(fingerprint_to_remove.to_string())
.into(),
));
}
}
SystemMessage::MemberAddedToGroup => {
ensure!(chat.typ != Chattype::OutBroadcast);
// TODO: lookup the contact by ID rather than email address.
// We are adding key-contacts, the cannot be looked up by address.
let email_to_add = msg.param.get(Param::Arg).unwrap_or_default();
@@ -1389,14 +1479,15 @@ impl MimeFactory {
));
}
if 0 != msg.param.get_int(Param::Arg2).unwrap_or_default() & DC_FROM_HANDSHAKE {
info!(
context,
"Sending secure-join message {:?}.", "vg-member-added",
);
let step = match chat.typ {
Chattype::Group => "vg-member-added",
Chattype::OutBroadcast => "vb-member-added",
_ => bail!("Wrong chattype {}", chat.typ),
};
info!(context, "Sending secure-join message {:?}.", step,);
headers.push((
"Secure-Join",
mail_builder::headers::raw::Raw::new("vg-member-added".to_string())
.into(),
mail_builder::headers::raw::Raw::new(step.to_string()).into(),
));
}
}
@@ -1472,7 +1563,10 @@ impl MimeFactory {
let param2 = msg.param.get(Param::Arg2).unwrap_or_default();
if !param2.is_empty() {
headers.push((
if step == "vg-request-with-auth" || step == "vc-request-with-auth" {
if step == "vg-request-with-auth"
|| step == "vc-request-with-auth"
|| step == "vb-request-with-auth"
{
"Secure-Join-Auth"
} else {
"Secure-Join-Invitenumber"
@@ -1510,7 +1604,7 @@ impl MimeFactory {
}
SystemMessage::IrohNodeAddr => {
headers.push((
"Iroh-Node-Addr",
HeaderDef::IrohNodeAddr.into(),
mail_builder::headers::text::Text::new(serde_json::to_string(
&context
.get_or_try_init_peer_channel()
@@ -1521,6 +1615,18 @@ impl MimeFactory {
.into(),
));
}
SystemMessage::CallAccepted => {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call-accepted").into(),
));
}
SystemMessage::CallEnded => {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call-ended").into(),
));
}
_ => {}
}
@@ -1545,6 +1651,17 @@ impl MimeFactory {
"Chat-Content",
mail_builder::headers::raw::Raw::new("videochat-invitation").into(),
));
} else if msg.viewtype == Viewtype::Call {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call").into(),
));
placeholdertext = Some(
"[This is a 'Call'. The sender uses an experiment not supported on your version yet]".to_string(),
);
}
if msg.param.exists(Param::WebrtcRoom) {
headers.push((
"Chat-Webrtc-Room",
mail_builder::headers::raw::Raw::new(
@@ -1555,6 +1672,17 @@ impl MimeFactory {
)
.into(),
));
} else if msg.param.exists(Param::WebrtcAccepted) {
headers.push((
"Chat-Webrtc-Accepted",
mail_builder::headers::raw::Raw::new(
msg.param
.get(Param::WebrtcAccepted)
.unwrap_or_default()
.to_string(),
)
.into(),
));
}
if msg.viewtype == Viewtype::Voice
@@ -1691,10 +1819,13 @@ impl MimeFactory {
let json = msg.param.get(Param::Arg).unwrap_or_default();
parts.push(context.build_status_update_part(json));
} else if msg.viewtype == Viewtype::Webxdc {
let topic = self
.webxdc_topic
.map(|top| BASE32_NOPAD.encode(top.as_bytes()).to_ascii_lowercase())
.unwrap_or(create_iroh_header(context, msg.id).await?);
headers.push((
"Iroh-Gossip-Topic",
mail_builder::headers::raw::Raw::new(create_iroh_header(context, msg.id).await?)
.into(),
HeaderDef::IrohGossipTopic.get_headername(),
mail_builder::headers::raw::Raw::new(topic).into(),
));
if let (Some(json), _) = context
.render_webxdc_status_update_object(
@@ -1784,6 +1915,29 @@ fn hidden_recipients() -> Address<'static> {
Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
}
fn should_encrypt_with_auth_token(msg: &Message) -> bool {
msg.is_vb_request_with_auth()
}
fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool {
chat.typ == Chattype::OutBroadcast
// The only `SystemMessage::SecurejoinMessage` that is ever sent into a broadcast,
// which is `vb-request-with-auth`,
// should be encrypted with the AUTH token rather than the broadcast secret.
&& msg.param.get_cmd() != SystemMessage::SecurejoinMessage
// The member-added message in a broadcast must be asymmetrically encrypted,
// because the newly-added member doesn't know the broadcast shared secret yet:
&& msg.param.get_cmd() != SystemMessage::MemberAddedToGroup
}
fn should_hide_recipients(msg: &Message, chat: &Chat) -> bool {
should_encrypt_with_broadcast_secret(msg, chat)
}
fn should_encrypt_symmetrically(msg: &Message, chat: &Chat) -> bool {
should_encrypt_with_auth_token(msg) || should_encrypt_with_broadcast_secret(msg, chat)
}
async fn build_body_file(context: &Context, msg: &Message) -> Result<MimePart<'static>> {
let file_name = msg.get_filename().context("msg has no file")?;
let blob = msg

View File

@@ -2,6 +2,7 @@ use deltachat_contact_tools::ContactAddress;
use mail_builder::headers::Header;
use mailparse::{MailHeaderMap, addrparse_header};
use std::str;
use std::time::Duration;
use super::*;
use crate::chat::{
@@ -16,6 +17,7 @@ use crate::message;
use crate::mimeparser::MimeMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager, get_chat_msg};
use crate::tools::SystemTime;
fn render_email_address(display_name: &str, addr: &str) -> String {
let mut output = Vec::<u8>::new();
@@ -89,8 +91,11 @@ fn test_render_rfc724_mid() {
fn render_header_text(text: &str) -> String {
let mut output = Vec::<u8>::new();
// Some non-zero length of the header name.
let bytes_written = 20;
mail_builder::headers::text::Text::new(text.to_string())
.write_header(&mut output, 0)
.write_header(&mut output, bytes_written)
.unwrap();
String::from_utf8(output).unwrap()
@@ -681,6 +686,7 @@ async fn test_selfavatar_unencrypted_signed() {
.unwrap()
.unwrap();
let alice_contact = Contact::get_by_id(&bob.ctx, alice_id).await.unwrap();
assert_eq!(alice_contact.is_key_contact(), false);
assert!(
alice_contact
.get_profile_image(&bob.ctx)
@@ -867,6 +873,43 @@ async fn test_dont_remove_self() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_new_member_is_first_recipient() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let bob_id = alice.add_or_lookup_contact_id(bob).await;
let charlie_id = alice.add_or_lookup_contact_id(charlie).await;
let group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "Group", &[bob])
.await;
alice.send_text(group, "Hi! I created a group.").await;
SystemTime::shift(Duration::from_secs(60));
add_contact_to_chat(alice, group, charlie_id).await?;
let sent_msg = alice.pop_sent_msg().await;
assert!(
sent_msg
.recipients
.starts_with(&charlie.get_config(Config::Addr).await?.unwrap())
);
remove_contact_from_chat(alice, group, bob_id).await?;
alice.pop_sent_msg().await;
SystemTime::shift(Duration::from_secs(60));
add_contact_to_chat(alice, group, bob_id).await?;
let sent_msg = alice.pop_sent_msg().await;
assert!(
sent_msg
.recipients
.starts_with(&bob.get_config(Config::Addr).await?.unwrap())
);
Ok(())
}
/// Regression test: mimefactory should never create an empty to header,
/// also not if the Selftalk parameter is missing
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -1,7 +1,7 @@
//! # MIME message parsing module.
use std::cmp::min;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use std::str;
use std::str::FromStr;
@@ -18,7 +18,6 @@ use crate::authres::handle_authres;
use crate::blob::BlobObject;
use crate::chat::ChatId;
use crate::config::Config;
use crate::constants;
use crate::contact::ContactId;
use crate::context::Context;
use crate::decrypt::{try_decrypt, validate_detached_signature};
@@ -35,6 +34,18 @@ use crate::tools::{
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
};
use crate::{chatlist_events, location, stock_str, tools};
use crate::{constants, token};
/// Public key extracted from `Autocrypt-Gossip`
/// header with associated information.
#[derive(Debug)]
pub struct GossipedKey {
/// Public key extracted from `keydata` attribute.
pub public_key: SignedPublicKey,
/// True if `Autocrypt-Gossip` has a `_verified` attribute.
pub verified: bool,
}
/// A parsed MIME message.
///
@@ -85,7 +96,7 @@ pub(crate) struct MimeMessage {
/// The addresses for which there was a gossip header
/// and their respective gossiped keys.
pub gossiped_keys: HashMap<String, SignedPublicKey>,
pub gossiped_keys: BTreeMap<String, GossipedKey>,
/// Fingerprint of the key in the Autocrypt header.
///
@@ -216,6 +227,12 @@ pub enum SystemMessage {
/// "Messages are end-to-end encrypted."
ChatE2ee = 50,
/// Message indicating that a call was accepted.
CallAccepted = 66,
/// Message indicating that a call was ended.
CallEnded = 67,
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
@@ -338,9 +355,22 @@ impl MimeMessage {
let mail_raw; // Memory location for a possible decrypted message.
let decrypted_msg; // Decrypted signed OpenPGP message.
let mut secrets: Vec<String> = context
.sql
.query_map(
"SELECT secret FROM broadcasts_shared_secrets",
(),
|row| row.get(0),
|rows| {
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await?;
secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?);
let (mail, is_encrypted) =
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring)) {
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring, &secrets)) {
Ok(Some(mut msg)) => {
mail_raw = msg.as_data_vec().unwrap_or_default();
@@ -427,7 +457,7 @@ impl MimeMessage {
None
};
let public_keyring = if incoming {
let mut public_keyring = if incoming {
if let Some(autocrypt_header) = autocrypt_header {
vec![autocrypt_header.public_key]
} else {
@@ -437,8 +467,46 @@ impl MimeMessage {
key::load_self_public_keyring(context).await?
};
if let Some(signature) = match &decrypted_msg {
Some(pgp::composed::Message::Literal { .. }) => None,
Some(pgp::composed::Message::Compressed { .. }) => {
// One layer of compression should already be handled by now.
// We don't decompress messages compressed multiple times.
None
}
Some(pgp::composed::Message::SignedOnePass { reader, .. }) => reader.signature(),
Some(pgp::composed::Message::Signed { reader, .. }) => Some(reader.signature()),
Some(pgp::composed::Message::Encrypted { .. }) => {
// The message is already decrypted once.
None
}
None => None,
} {
for issuer_fingerprint in signature.issuer_fingerprint() {
let issuer_fingerprint =
crate::key::Fingerprint::from(issuer_fingerprint.clone()).hex();
if let Some(public_key_bytes) = context
.sql
.query_row_optional(
"SELECT public_key
FROM public_keys
WHERE fingerprint=?",
(&issuer_fingerprint,),
|row| {
let bytes: Vec<u8> = row.get(0)?;
Ok(bytes)
},
)
.await?
{
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
public_keyring.push(public_key)
}
}
}
let mut signatures = if let Some(ref decrypted_msg) = decrypted_msg {
crate::pgp::valid_signature_fingerprints(decrypted_msg, &public_keyring)?
crate::pgp::valid_signature_fingerprints(decrypted_msg, &public_keyring)
} else {
HashSet::new()
};
@@ -638,6 +706,10 @@ impl MimeMessage {
self.is_system_message = SystemMessage::ChatProtectionDisabled;
} else if value == "group-avatar-changed" {
self.is_system_message = SystemMessage::GroupImageChanged;
} else if value == "call-accepted" {
self.is_system_message = SystemMessage::CallAccepted;
} else if value == "call-ended" {
self.is_system_message = SystemMessage::CallEnded;
}
} else if self.get_header(HeaderDef::ChatGroupMemberRemoved).is_some() {
self.is_system_message = SystemMessage::MemberRemovedFromGroup;
@@ -660,16 +732,26 @@ impl MimeMessage {
}
fn parse_videochat_headers(&mut self) {
if let Some(value) = self.get_header(HeaderDef::ChatContent) {
if value == "videochat-invitation" {
let instance = self
.get_header(HeaderDef::ChatWebrtcRoom)
.map(|s| s.to_string());
if let Some(part) = self.parts.first_mut() {
let content = self
.get_header(HeaderDef::ChatContent)
.unwrap_or_default()
.to_string();
let room = self
.get_header(HeaderDef::ChatWebrtcRoom)
.map(|s| s.to_string());
let accepted = self
.get_header(HeaderDef::ChatWebrtcAccepted)
.map(|s| s.to_string());
if let Some(part) = self.parts.first_mut() {
if let Some(room) = room {
if content == "videochat-invitation" {
part.typ = Viewtype::VideochatInvitation;
part.param
.set(Param::WebrtcRoom, instance.unwrap_or_default());
} else if content == "call" {
part.typ = Viewtype::Call
}
part.param.set(Param::WebrtcRoom, room);
} else if let Some(accepted) = accepted {
part.param.set(Param::WebrtcAccepted, accepted);
}
}
}
@@ -695,7 +777,10 @@ impl MimeMessage {
| Viewtype::Vcard
| Viewtype::File
| Viewtype::Webxdc => true,
Viewtype::Unknown | Viewtype::Text | Viewtype::VideochatInvitation => false,
Viewtype::Unknown
| Viewtype::Text
| Viewtype::VideochatInvitation
| Viewtype::Call => false,
})
{
let mut parts = std::mem::take(&mut self.parts);
@@ -1322,10 +1407,6 @@ impl MimeMessage {
filename: &str,
is_related: bool,
) -> Result<()> {
if decoded_data.is_empty() {
return Ok(());
}
// Process attached PGP keys.
if mime_type.type_() == mime::APPLICATION
&& mime_type.subtype().as_str() == "pgp-keys"
@@ -1451,7 +1532,7 @@ impl MimeMessage {
);
return Ok(false);
}
Ok((key, _)) => key,
Ok(key) => key,
};
if let Err(err) = key.verify() {
warn!(context, "Attached PGP key verification failed: {err:#}.");
@@ -1514,6 +1595,13 @@ impl MimeMessage {
}
}
/// Check if a message is a call.
pub(crate) fn is_call(&self) -> bool {
self.parts
.first()
.is_some_and(|part| part.typ == Viewtype::Call)
}
pub fn replace_msg_by_error(&mut self, error_msg: &str) {
self.is_system_message = SystemMessage::Unknown;
if let Some(part) = self.parts.first_mut() {
@@ -1908,9 +1996,9 @@ async fn parse_gossip_headers(
from: &str,
recipients: &[SingleInfo],
gossip_headers: Vec<String>,
) -> Result<HashMap<String, SignedPublicKey>> {
) -> Result<BTreeMap<String, GossipedKey>> {
// XXX split the parsing from the modification part
let mut gossiped_keys: HashMap<String, SignedPublicKey> = Default::default();
let mut gossiped_keys: BTreeMap<String, GossipedKey> = Default::default();
for value in &gossip_headers {
let header = match value.parse::<Aheader>() {
@@ -1952,7 +2040,12 @@ async fn parse_gossip_headers(
)
.await?;
gossiped_keys.insert(header.addr.to_lowercase(), header.public_key);
let gossiped_key = GossipedKey {
public_key: header.public_key,
verified: header.verified,
};
gossiped_keys.insert(header.addr.to_lowercase(), gossiped_key);
}
Ok(gossiped_keys)

View File

@@ -227,9 +227,6 @@ pub(crate) async fn update_connect_timestamp(
}
/// Preloaded DNS results that can be used in case of DNS server failures.
///
/// See <https://support.delta.chat/t/no-dns-resolution-result/2778> and
/// <https://github.com/deltachat/deltachat-core-rust/issues/4920> for reasons.
static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new(|| {
HashMap::from([
(

View File

@@ -244,7 +244,7 @@ async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
.clone();
let req = hyper::Request::builder()
.uri(parsed_url.path())
.uri(parsed_url)
.header(hyper::header::HOST, authority.as_str())
.body(http_body_util::Empty::<Bytes>::new())?;
let response = sender.send_request(req).await?;
@@ -378,7 +378,7 @@ pub(crate) async fn post_string(context: &Context, url: &str, body: String) -> R
.context("URL has no authority")?
.clone();
let request = hyper::Request::post(parsed_url.path())
let request = hyper::Request::post(parsed_url)
.header(hyper::header::HOST, authority.as_str())
.body(body)?;
let response = sender.send_request(request).await?;
@@ -408,7 +408,7 @@ pub(crate) async fn post_form<T: Serialize + ?Sized>(
.authority()
.context("URL has no authority")?
.clone();
let request = hyper::Request::post(parsed_url.path())
let request = hyper::Request::post(parsed_url)
.header(hyper::header::HOST, authority.as_str())
.header("content-type", "application/x-www-form-urlencoded")
.body(encoded_body)?;

View File

@@ -99,19 +99,42 @@ pub enum Param {
/// For Messages
///
/// For "MemberRemovedFromGroup" this is the email address
/// For "MemberRemovedFromGroup", this is the email address
/// removed from the group.
///
/// For "MemberAddedToGroup" this is the email address added to the group.
/// For "MemberAddedToGroup", this is the email address added to the group.
///
/// For securejoin messages, this is the step,
/// which is put into the `Secure-Join` header.
Arg = b'E',
/// For Messages
///
/// For `BobHandshakeMsg::Request`, this is the `Secure-Join-Invitenumber` header.
///
/// For `BobHandshakeMsg::RequestWithAuth`, this is the `Secure-Join-Auth` header.
///
/// For version two of the securejoin protocol (`vb-request-with-auth`),
/// this is the Auth token used to encrypt the message.
///
/// For [`SystemMessage::MultiDeviceSync`], this contains the ids that are synced.
///
/// For [`SystemMessage::MemberAddedToGroup`],
/// this is '1' if it was added because of a securejoin-handshake, and '0' otherwise.
Arg2 = b'F',
/// `Secure-Join-Fingerprint` header for `{vc,vg}-request-with-auth` messages.
/// For Messages
///
/// For `BobHandshakeMsg::RequestWithAuth`,
/// this contains the `Secure-Join-Fingerprint` header.
///
/// For [`SystemMessage::MemberAddedToGroup`] that add to a broadcast channel,
/// this contains the broadcast channel's shared secret.
Arg3 = b'G',
/// Deprecated `Secure-Join-Group` header for messages.
/// For Messages
///
/// Deprecated `Secure-Join-Group` header for `BobHandshakeMsg::RequestWithAuth` messages.
Arg4 = b'H',
/// For Messages
@@ -120,6 +143,9 @@ pub enum Param {
/// For Messages
WebrtcRoom = b'V',
/// For Messages
WebrtcAccepted = b'7',
/// For Messages: space-separated list of messaged IDs of forwarded copies.
///
/// This is used when a [crate::message::Message] is in the

View File

@@ -48,13 +48,13 @@ use crate::mimeparser::SystemMessage;
const PUBLIC_KEY_LENGTH: usize = 32;
const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
/// Store iroh peer channels for the context.
/// Store Iroh peer channels for the context.
#[derive(Debug)]
pub struct Iroh {
/// iroh router needed for iroh peer channels.
/// Iroh router needed for Iroh peer channels.
pub(crate) router: iroh::protocol::Router,
/// [Gossip] needed for iroh peer channels.
/// [Gossip] needed for Iroh peer channels.
pub(crate) gossip: Gossip,
/// Sequence numbers for gossip channels.
@@ -109,7 +109,7 @@ impl Iroh {
info!(
ctx,
"IROH_REALTIME: Joining gossip with peers: {:?}", node_ids,
"IROH_REALTIME: Joining gossip {topic} with peers: {:?}.", node_ids,
);
// Inform iroh of potentially new node addresses
@@ -138,17 +138,11 @@ impl Iroh {
Ok(Some(join_rx))
}
/// Add gossip peers to realtime channel if it is already active.
pub async fn maybe_add_gossip_peers(&self, topic: TopicId, peers: Vec<NodeAddr>) -> Result<()> {
/// Add gossip peer to realtime channel if it is already active.
pub async fn maybe_add_gossip_peer(&self, topic: TopicId, peer: NodeAddr) -> Result<()> {
if self.iroh_channels.read().await.get(&topic).is_some() {
for peer in &peers {
self.router.endpoint().add_node_addr(peer.clone())?;
}
self.gossip.subscribe_with_opts(
topic,
JoinOptions::with_bootstrap(peers.into_iter().map(|peer| peer.node_id)),
);
self.router.endpoint().add_node_addr(peer.clone())?;
self.gossip.subscribe(topic, vec![peer.node_id])?;
}
Ok(())
}
@@ -316,6 +310,17 @@ impl Context {
}
}
}
pub(crate) async fn maybe_add_gossip_peer(&self, topic: TopicId, peer: NodeAddr) -> Result<()> {
if let Some(iroh) = &*self.iroh.read().await {
info!(
self,
"Adding (maybe existing) peer with id {} to {topic}.", peer.node_id
);
iroh.maybe_add_gossip_peer(topic, peer).await?;
}
Ok(())
}
}
/// Cache a peers [NodeId] for one topic.
@@ -348,13 +353,14 @@ pub async fn add_gossip_peer_from_header(
return Ok(());
}
info!(
context,
"Adding iroh peer with address {node_addr:?} to the topic of {instance_id}."
);
let node_addr =
serde_json::from_str::<NodeAddr>(node_addr).context("Failed to parse node address")?;
info!(
context,
"Adding iroh peer with node id {} to the topic of {instance_id}.", node_addr.node_id
);
context.emit_event(EventType::WebxdcRealtimeAdvertisementReceived {
msg_id: instance_id,
});
@@ -371,8 +377,7 @@ pub async fn add_gossip_peer_from_header(
let relay_server = node_addr.relay_url().map(|relay| relay.as_str());
iroh_add_peer_for_topic(context, instance_id, topic, node_id, relay_server).await?;
let iroh = context.get_or_try_init_peer_channel().await?;
iroh.maybe_add_gossip_peers(topic, vec![node_addr]).await?;
context.maybe_add_gossip_peer(topic, node_addr).await?;
Ok(())
}
@@ -555,9 +560,9 @@ mod tests {
use super::*;
use crate::{
EventType,
chat::send_msg,
chat::{self, ChatId, ProtectionStatus, add_contact_to_chat, resend_msgs, send_msg},
message::{Message, Viewtype},
test_utils::TestContextManager,
test_utils::{TestContext, TestContextManager},
};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -924,8 +929,8 @@ mod tests {
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
// Alice sends webxdc to bob
let alice_chat = alice.create_chat(bob).await;
let chat = alice.create_chat(bob).await.id;
let mut instance = Message::new(Viewtype::File);
instance
.set_file_from_bytes(
@@ -935,7 +940,100 @@ mod tests {
None,
)
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
connect_alice_bob(alice, chat, &mut instance, bob).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_webxdc_resend() {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
let group = chat::create_group_chat(alice, ProtectionStatus::Unprotected, "group chat")
.await
.unwrap();
// Alice sends webxdc to bob
let mut instance = Message::new(Viewtype::File);
instance
.set_file_from_bytes(
alice,
"minimal.xdc",
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.unwrap();
add_contact_to_chat(alice, group, alice.add_or_lookup_contact_id(bob).await)
.await
.unwrap();
connect_alice_bob(alice, group, &mut instance, bob).await;
// fiona joins late
let fiona = &mut tcm.fiona().await;
add_contact_to_chat(alice, group, alice.add_or_lookup_contact_id(fiona).await)
.await
.unwrap();
resend_msgs(alice, &[instance.id]).await.unwrap();
let msg = alice.pop_sent_msg().await;
let fiona_instance = fiona.recv_msg(&msg).await;
fiona_instance.chat_id.accept(fiona).await.unwrap();
assert!(fiona.ctx.iroh.read().await.is_none());
let fiona_connect_future = send_webxdc_realtime_advertisement(fiona, fiona_instance.id)
.await
.unwrap()
.unwrap();
let fiona_advert = fiona.pop_sent_msg().await;
alice.recv_msg_trash(&fiona_advert).await;
fiona_connect_future.await.unwrap();
let realtime_send_loop = async {
// Keep sending in a loop because right after joining
// Fiona may miss messages.
loop {
send_webxdc_realtime_data(alice, instance.id, b"alice -> bob & fiona".into())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
};
let realtime_receive_loop = async {
loop {
let event = fiona.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"alice -> bob & fiona" {
break;
} else {
panic!(
"Unexpected status update: {}",
String::from_utf8_lossy(&data)
);
}
}
}
};
tokio::select!(
_ = realtime_send_loop => {
panic!("Send loop should never finish");
},
_ = realtime_receive_loop => {
return;
}
);
}
async fn connect_alice_bob(
alice: &mut TestContext,
alice_chat_id: ChatId,
instance: &mut Message,
bob: &mut TestContext,
) {
send_msg(alice, alice_chat_id, instance).await.unwrap();
let alice_webxdc = alice.get_last_msg().await;
let webxdc = alice.pop_sent_msg().await;
@@ -952,8 +1050,9 @@ mod tests {
.unwrap();
let alice_advertisement = alice.pop_sent_msg().await;
send_webxdc_realtime_advertisement(bob, bob_webxdc.id)
let bob_advertisement_future = send_webxdc_realtime_advertisement(bob, bob_webxdc.id)
.await
.unwrap()
.unwrap();
let bob_advertisement = bob.pop_sent_msg().await;
@@ -961,8 +1060,9 @@ mod tests {
bob.recv_msg_trash(&alice_advertisement).await;
alice.recv_msg_trash(&bob_advertisement).await;
eprintln!("Alice waits for connection");
eprintln!("Alice and Bob wait for connection");
alice_advertisement_future.await.unwrap();
bob_advertisement_future.await.unwrap();
// Alice sends ephemeral message
eprintln!("Sending ephemeral message");

View File

@@ -3,7 +3,7 @@
use std::collections::{BTreeMap, HashSet};
use std::io::{BufRead, Cursor};
use anyhow::{Context as _, Result};
use anyhow::{Context as _, Result, bail};
use chrono::SubsecRound;
use deltachat_contact_tools::EmailAddress;
use pgp::armor::BlockType;
@@ -12,12 +12,13 @@ use pgp::composed::{
SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
StandaloneSignature, SubkeyParamsBuilder, TheRing,
};
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
use pgp::crypto::ecc_curve::ECCCurve;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::types::{CompressionAlgorithm, KeyDetails, Password, PublicKeyTrait, StringToKey};
use rand::thread_rng;
use rand::{Rng as _, thread_rng};
use tokio::runtime::Handle;
use crate::key::{DcKey, Fingerprint};
@@ -25,7 +26,7 @@ use crate::key::{DcKey, Fingerprint};
#[cfg(test)]
pub(crate) const HEADER_AUTOCRYPT: &str = "autocrypt-prefer-encrypt";
pub const HEADER_SETUPCODE: &str = "passphrase-begin";
pub(crate) const HEADER_SETUPCODE: &str = "passphrase-begin";
/// Preferred symmetric encryption algorithm.
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
@@ -231,13 +232,17 @@ pub fn pk_calc_signature(
Ok(sig.to_armored_string(ArmorOptions::default())?)
}
/// Decrypts the message with keys from the private key keyring.
/// Decrypts the message:
/// - with keys from the private key keyring (passed in `private_keys_for_decryption`)
/// if the message was asymmetrically encrypted,
/// - with a shared secret/password (passed in `shared_secrets`),
/// if the message was symmetrically encrypted.
///
/// Receiver private keys are provided in
/// `private_keys_for_decryption`.
pub fn pk_decrypt(
/// Returns the decrypted and decompressed message.
pub fn decrypt(
ctext: Vec<u8>,
private_keys_for_decryption: &[SignedSecretKey],
mut shared_secrets: &[String],
) -> Result<pgp::composed::Message<'static>> {
let cursor = Cursor::new(ctext);
let (msg, _headers) = Message::from_armor(cursor)?;
@@ -245,18 +250,43 @@ pub fn pk_decrypt(
let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect();
let empty_pw = Password::empty();
let try_symmetric_decryption = should_try_symmetric_decryption(&msg);
if try_symmetric_decryption.is_err() {
shared_secrets = &[];
}
// We always try out all passwords here, which is not great for performance.
// But benchmarking (see `benchmark_decrypting.rs`)
// showed that the performance penalty is acceptable.
// We could include a short (~2 character) identifier of the secret in cleartext
// (or just include the first 2 characters of the secret in cleartext)
// in order to narrow down the number of shared secrets that have to be tried out.
let message_password: Vec<Password> = shared_secrets
.iter()
.map(|p| Password::from(p.as_str()))
.collect();
let message_password: Vec<&Password> = message_password.iter().collect();
let ring = TheRing {
secret_keys: skeys,
key_passwords: vec![&empty_pw],
message_password: vec![],
message_password,
session_keys: vec![],
allow_legacy: false,
};
let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?;
anyhow::ensure!(
!ring_result.secret_keys.is_empty(),
"decryption failed, no matching secret keys"
);
let res = msg.decrypt_the_ring(ring, true);
let (msg, _ring_result) = match res {
Ok(it) => it,
Err(err) => {
if let Err(reason) = try_symmetric_decryption {
bail!("{err:#} (Note: symmetric decryption was not tried: {reason})")
} else {
bail!("{err:#}");
}
}
};
// remove one layer of compression
let msg = msg.decompress()?;
@@ -264,6 +294,34 @@ pub fn pk_decrypt(
Ok(msg)
}
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
/// and Err with a reason if symmetric decryption should not be tried.
///
/// A DOS attacker could send a message with a lot of encrypted session keys,
/// all of which use a very hard-to-compute string2key algorithm.
/// We would then try to decrypt all of the encrypted session keys
/// with all of the known shared secrets.
/// In order to prevent this, we do not try to symmetrically decrypt messages
/// that use a string2key algorithm other than 'Salted'.
fn should_try_symmetric_decryption(msg: &Message<'_>) -> std::result::Result<(), &'static str> {
let Message::Encrypted { esk, .. } = msg else {
return Err("not encrypted");
};
if esk.len() > 1 {
return Err("too many esks");
}
let [pgp::composed::Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..] else {
return Err("not symmetrically encrypted");
};
match esk.s2k() {
Some(StringToKey::Salted { .. }) => Ok(()),
_ => Err("unsupported string2key algorithm"),
}
}
/// Returns fingerprints
/// of all keys from the `public_keys_for_validation` keyring that
/// have valid signatures there.
@@ -272,7 +330,7 @@ pub fn pk_decrypt(
pub fn valid_signature_fingerprints(
msg: &pgp::composed::Message,
public_keys_for_validation: &[SignedPublicKey],
) -> Result<HashSet<Fingerprint>> {
) -> HashSet<Fingerprint> {
let mut ret_signature_fingerprints: HashSet<Fingerprint> = Default::default();
if msg.is_signed() {
for pkey in public_keys_for_validation {
@@ -282,7 +340,7 @@ pub fn valid_signature_fingerprints(
}
}
}
Ok(ret_signature_fingerprints)
ret_signature_fingerprints
}
/// Validates detached signature.
@@ -304,8 +362,8 @@ pub fn pk_validate(
Ok(ret)
}
/// Symmetric encryption.
pub async fn symm_encrypt(passphrase: &str, plain: Vec<u8>) -> Result<String> {
/// Symmetric encryption for the autocrypt setup file.
pub async fn symm_encrypt_setup_file(passphrase: &str, plain: Vec<u8>) -> Result<String> {
let passphrase = Password::from(passphrase.to_string());
tokio::task::spawn_blocking(move || {
@@ -322,6 +380,46 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec<u8>) -> Result<String> {
.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.
pub async fn symm_encrypt_message(
plain: Vec<u8>,
shared_secret: &str,
private_key_for_signing: SignedSecretKey,
compress: bool,
) -> Result<String> {
let shared_secret = Password::from(shared_secret.to_string());
tokio::task::spawn_blocking(move || {
let msg = MessageBuilder::from_bytes("", plain);
let mut rng = thread_rng();
let mut salt = [0u8; 8];
rng.fill(&mut salt[..]);
let s2k = StringToKey::Salted {
hash_alg: HashAlgorithm::default(),
salt,
};
let mut msg = msg.seipd_v2(
&mut rng,
SymmetricKeyAlgorithm::AES128,
AeadAlgorithm::Ocb,
ChunkSize::C8KiB,
);
msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?;
msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM);
if compress {
msg.compression(CompressionAlgorithm::ZLIB);
}
let encoded_msg = msg.to_armored_string(&mut rng, Default::default())?;
Ok(encoded_msg)
})
.await?
}
/// Symmetric decryption.
pub async fn symm_decrypt<T: BufRead + std::fmt::Debug + 'static + Send>(
passphrase: &str,
@@ -345,7 +443,10 @@ mod tests {
use tokio::sync::OnceCell;
use super::*;
use crate::test_utils::{alice_keypair, bob_keypair};
use crate::{
key::{load_self_public_key, load_self_secret_key},
test_utils::{TestContextManager, alice_keypair, bob_keypair},
};
fn pk_decrypt_and_validate<'a>(
ctext: &'a [u8],
@@ -356,10 +457,10 @@ mod tests {
HashSet<Fingerprint>,
Vec<u8>,
)> {
let mut msg = pk_decrypt(ctext.to_vec(), private_keys_for_decryption)?;
let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?;
let content = msg.as_data_vec()?;
let ret_signature_fingerprints =
valid_signature_fingerprints(&msg, public_keys_for_validation)?;
valid_signature_fingerprints(&msg, public_keys_for_validation);
Ok((msg, ret_signature_fingerprints, content))
}
@@ -542,4 +643,103 @@ mod tests {
assert_eq!(content, CLEARTEXT);
assert_eq!(valid_signatures.len(), 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_encrypt_decrypt_broadcast() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let plain = Vec::from(b"this is the secret message");
let shared_secret = "shared secret";
let ctext = symm_encrypt_message(
plain.clone(),
shared_secret,
load_self_secret_key(alice).await?,
true,
)
.await?;
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let mut decrypted = decrypt(
ctext.into(),
&bob_private_keyring,
&[shared_secret.to_string()],
)?;
assert_eq!(decrypted.as_data_vec()?, plain);
Ok(())
}
/// Test that we don't try to decrypt a message
/// that is symmetrically encrypted
/// with an expensive string2key algorithm
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_decrypt_expensive_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let plain = Vec::from(b"this is the secret message");
let shared_secret = "shared secret";
// Create a symmetrically encrypted message
// with an IteratedAndSalted string2key algorithm:
let shared_secret_pw = Password::from(shared_secret.to_string());
let msg = MessageBuilder::from_bytes("", plain);
let mut rng = thread_rng();
let s2k = StringToKey::new_default(&mut rng); // Default is IteratedAndSalted
let mut msg = msg.seipd_v2(
&mut rng,
SymmetricKeyAlgorithm::AES128,
AeadAlgorithm::Ocb,
ChunkSize::C8KiB,
);
msg.encrypt_with_password(&mut rng, s2k, &shared_secret_pw)?;
let ctext = msg.to_armored_string(&mut rng, Default::default())?;
// Trying to decrypt it should fail with a helpful error message:
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let error = decrypt(
ctext.into(),
&bob_private_keyring,
&[shared_secret.to_string()],
)
.unwrap_err();
assert_eq!(
error.to_string(),
"missing key (Note: symmetric decryption was not tried: unsupported string2key algorithm)"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decryption_error_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let plain = Vec::from(b"this is the secret message");
let pk_for_encryption = load_self_public_key(alice).await?;
// Encrypt a message, but only to self, not to Bob:
let ctext = pk_encrypt(plain, vec![pk_for_encryption], None, true).await?;
// Trying to decrypt it should fail with an OK error message:
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let error = decrypt(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
assert_eq!(
error.to_string(),
"missing key (Note: symmetric decryption was not tried: not symmetrically encrypted)"
);
Ok(())
}
}

View File

@@ -13,8 +13,8 @@ use std::sync::LazyLock;
// 163.md: 163.com
static P_163: Provider = Provider {
id: "163",
status: Status::Ok,
before_login_hint: "",
status: Status::Preparation,
before_login_hint: "Enable \"POP3/SMTP/IMAP\" on the website, add a third-party auth code and use that as the login password",
after_login_hint: "",
overview_page: "https://providers.delta.chat/163",
server: &[
@@ -98,7 +98,7 @@ static P_ALIYUN: Provider = Provider {
static P_AOL: Provider = Provider {
id: "aol",
status: Status::Preparation,
before_login_hint: "To log in to AOL with Delta Chat, you need to set up an app password in the AOL web interface.",
before_login_hint: "To log in to AOL, you need to set up an app password in the AOL web interface.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/aol",
server: &[
@@ -432,7 +432,7 @@ static P_EXAMPLE_COM: Provider = Provider {
id: "example.com",
status: Status::Broken,
before_login_hint: "Hush this provider doesn't exist!",
after_login_hint: "This provider doesn't really exist, so you can't use it :/ If you need an email provider for Delta Chat, take a look at providers.delta.chat!",
after_login_hint: "This provider doesn't really exist, so you can't use it :/ If you need an email provider, take a look at providers.delta.chat!",
overview_page: "https://providers.delta.chat/example-com",
server: &[
Server {
@@ -459,7 +459,7 @@ static P_EXAMPLE_COM: Provider = Provider {
static P_FASTMAIL: Provider = Provider {
id: "fastmail",
status: Status::Preparation,
before_login_hint: "You must create an app-specific password for Delta Chat before you can log in.",
before_login_hint: "You must create an app-specific password before you can log in.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/fastmail",
server: &[
@@ -526,7 +526,7 @@ static P_FIVE_CHAT: Provider = Provider {
static P_FREENET_DE: Provider = Provider {
id: "freenet.de",
status: Status::Preparation,
before_login_hint: "Um deine freenet.de E-Mail-Adresse mit Delta Chat zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.",
before_login_hint: "Um deine freenet.de E-Mail-Adresse zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/freenet-de",
server: &[
@@ -647,10 +647,6 @@ static P_HERMES_RADIO: Provider = Provider {
key: Config::MdnsEnabled,
value: "0",
},
ConfigDefault {
key: Config::E2eeEnabled,
value: "0",
},
ConfigDefault {
key: Config::ShowEmails,
value: "2",
@@ -663,7 +659,7 @@ static P_HERMES_RADIO: Provider = Provider {
static P_HEY_COM: Provider = Provider {
id: "hey.com",
status: Status::Broken,
before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to hey.com.",
before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in to hey.com.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/hey-com",
server: &[],
@@ -702,7 +698,7 @@ static P_I3_NET: Provider = Provider {
static P_ICLOUD: Provider = Provider {
id: "icloud",
status: Status::Preparation,
before_login_hint: "You must create an app-specific password for Delta Chat before login.",
before_login_hint: "You must create an app-specific password before login.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/icloud",
server: &[
@@ -787,7 +783,7 @@ static P_KONTENT_COM: Provider = Provider {
static P_MAIL_COM: Provider = Provider {
id: "mail.com",
status: Status::Preparation,
before_login_hint: "To log in with Delta Chat, you first need to activate POP3/IMAP in your mail.com settings. Note that this is a mail.com Premium feature only.",
before_login_hint: "To log in, you first need to activate POP3/IMAP in your mail.com settings. Note that this is a mail.com Premium feature only.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/mail-com",
server: &[],
@@ -828,7 +824,7 @@ static P_MAIL_DE: Provider = Provider {
static P_MAIL_RU: Provider = Provider {
id: "mail.ru",
status: Status::Preparation,
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru, чтобы mail.ru работал с Delta Chat.",
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru, чтобы mail.ru работал с chatmail.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/mail-ru",
server: &[
@@ -1222,8 +1218,8 @@ static P_NUBO_COOP: Provider = Provider {
// outlook.com.md: hotmail.com, outlook.com, office365.com, outlook.com.tr, live.com, outlook.de
static P_OUTLOOK_COM: Provider = Provider {
id: "outlook.com",
status: Status::Ok,
before_login_hint: "",
status: Status::Broken,
before_login_hint: "Unfortunately, Outlook does not allow using passwords anymore, per-app-passwords are currently not working.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/outlook-com",
server: &[
@@ -1321,8 +1317,8 @@ static P_POSTEO: Provider = Provider {
static P_PROTONMAIL: Provider = Provider {
id: "protonmail",
status: Status::Broken,
before_login_hint: "Protonmail does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to Protonmail.",
after_login_hint: "To use Delta Chat with Protonmail, the IMAP bridge must be running in the background. If you have connectivity issues, double check whether it works as expected.",
before_login_hint: "Protonmail does not offer the standard IMAP e-mail protocol, so you cannot log in with to Protonmail.",
after_login_hint: "To use Protonmail, the IMAP bridge must be running in the background. If you have connectivity issues, double check whether it works as expected.",
overview_page: "https://providers.delta.chat/protonmail",
server: &[],
opt: ProviderOptions::new(),
@@ -1362,7 +1358,7 @@ static P_PURELYMAIL_COM: Provider = Provider {
static P_QQ: Provider = Provider {
id: "qq",
status: Status::Preparation,
before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password for Delta Chat are required.",
before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password are required.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/qq",
server: &[
@@ -1390,7 +1386,7 @@ static P_QQ: Provider = Provider {
static P_RAMBLER_RU: Provider = Provider {
id: "rambler.ru",
status: Status::Preparation,
before_login_hint: "Чтобы войти в Рамблер/почта через Delta Chat, необходимо предварительно включить доступ с помощью почтовых клиентов на сайте mail.rambler.ru",
before_login_hint: "Чтобы войти в Рамблер/почта, необходимо предварительно включить доступ с помощью почтовых клиентов на сайте mail.rambler.ru",
after_login_hint: "",
overview_page: "https://providers.delta.chat/rambler-ru",
server: &[
@@ -1566,7 +1562,7 @@ static P_SYSTEMLI_ORG: Provider = Provider {
static P_T_ONLINE: Provider = Provider {
id: "t-online",
status: Status::Preparation,
before_login_hint: "To use Delta Chat with a T-Online email address, you need to create an app password in the web interface.",
before_login_hint: "To use a T-Online email address, you need to create an app password in the web interface.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/t-online",
server: &[
@@ -1677,7 +1673,7 @@ static P_TISCALI_IT: Provider = Provider {
static P_TUTANOTA: Provider = Provider {
id: "tutanota",
status: Status::Broken,
before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to Tutanota.",
before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in to Tutanota.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/tutanota",
server: &[],
@@ -1787,7 +1783,7 @@ static P_VIVALDI: Provider = Provider {
static P_VK_COM: Provider = Provider {
id: "vk.com",
status: Status::Preparation,
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru https://account.mail.ru/user/2-step-auth/passwords/ чтобы vk.com работал с Delta Chat.",
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru https://account.mail.ru/user/2-step-auth/passwords/ чтобы vk.com работал с chatmail.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/vk-com",
server: &[
@@ -1906,7 +1902,7 @@ static P_WKPB_DE: Provider = Provider {
static P_YAHOO: Provider = Provider {
id: "yahoo",
status: Status::Preparation,
before_login_hint: "To use Delta Chat with your Yahoo email address you have to create an \"App-Password\" in the account security screen.",
before_login_hint: "To use your Yahoo email address you have to create an \"App-Password\" in the account security screen.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/yahoo",
server: &[
@@ -2662,4 +2658,4 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
});
pub static _PROVIDER_UPDATED: LazyLock<chrono::NaiveDate> =
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2024, 9, 13).unwrap());
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2025, 9, 4).unwrap());

View File

@@ -74,7 +74,7 @@ fn pad_device_token(s: &str) -> String {
///
/// The result is base64-encoded and not ASCII armored to avoid dealing with newlines.
pub(crate) fn encrypt_device_token(device_token: &str) -> Result<String> {
let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?.0;
let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?;
let encryption_subkey = public_key
.public_subkeys
.first()

View File

@@ -84,6 +84,30 @@ pub enum Qr {
authcode: String,
},
/// Ask whether to join the broadcast channel.
AskJoinBroadcast {
/// The user-visible name of this broadcast channel
broadcast_name: String,
/// A string of random characters,
/// uniquely identifying this broadcast channel in the database.
/// Called `grpid` for historic reasons:
/// The id of multi-user chats is always called `grpid` in the database
/// because groups were once the only multi-user chats.
grpid: String,
/// ID of the contact who owns the channel and created the QR code.
contact_id: ContactId,
/// Fingerprint of the contact's key as scanned from the QR code.
fingerprint: Fingerprint,
/// The AUTH code from the secure-join protocol,
/// which is both used to encrypt the first message to the inviter
/// and to prove to the inviter that we saw the QR code.
authcode: String,
},
/// Contact fingerprint is verified.
///
/// Ask the user if they want to start chatting.
@@ -381,6 +405,7 @@ pub fn format_backup(qr: &Qr) -> Result<String> {
/// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH`
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH`
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&b=BROADCAST_NAME&x=BROADCAST_ID&s=AUTH`
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR`
async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
let payload = qr
@@ -417,15 +442,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
None
};
let name = if let Some(encoded_name) = param.get("n") {
let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
match percent_decode_str(&encoded_name).decode_utf8() {
Ok(name) => name.to_string(),
Err(err) => bail!("Invalid name: {}", err),
}
} else {
"".to_string()
};
let name = decode_name(&param, "n")?.unwrap_or_default();
let invitenumber = param
.get("i")
@@ -440,21 +457,12 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
.filter(|&s| validate_id(s))
.map(|s| s.to_string());
let grpname = if grpid.is_some() {
if let Some(encoded_name) = param.get("g") {
let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
match percent_decode_str(&encoded_name).decode_utf8() {
Ok(name) => Some(name.to_string()),
Err(err) => bail!("Invalid group name: {}", err),
}
} else {
None
}
} else {
None
};
let grpname = decode_name(&param, "g")?;
let broadcast_name = decode_name(&param, "b")?;
if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
if let (Some(addr), Some(invitenumber), Some(authcode)) =
(&addr, invitenumber, authcode.clone())
{
let addr = ContactAddress::new(addr)?;
let (contact_id, _) = Contact::add_or_lookup_ex(
context,
@@ -525,6 +533,28 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
authcode,
})
}
} else if let (Some(addr), Some(broadcast_name), Some(grpid), Some(authcode)) =
(&addr, broadcast_name, grpid, authcode)
{
// This is a broadcast channel invite link.
let addr = ContactAddress::new(addr)?;
let (contact_id, _) = Contact::add_or_lookup_ex(
context,
&name,
&addr,
&fingerprint.hex(),
Origin::UnhandledSecurejoinQrScan,
)
.await
.with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
Ok(Qr::AskJoinBroadcast {
broadcast_name,
grpid,
contact_id,
fingerprint,
authcode,
})
} else if let Some(addr) = addr {
let fingerprint = fingerprint.hex();
let (contact_id, _) =
@@ -546,6 +576,18 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
}
}
fn decode_name(param: &BTreeMap<&str, &str>, key: &str) -> Result<Option<String>> {
if let Some(encoded_name) = param.get(key) {
let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
match percent_decode_str(&encoded_name).decode_utf8() {
Ok(name) => Ok(Some(name.to_string())),
Err(err) => bail!("Invalid QR param {key}: {err}"),
}
} else {
Ok(None)
}
}
/// scheme: `https://i.delta.chat[/]#FINGERPRINT&a=ADDR[&OPTIONAL_PARAMS]`
async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
@@ -766,19 +808,18 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
authcode,
..
} => {
token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
token::delete(context, token::Namespace::Auth, &authcode).await?;
token::delete(context, "").await?;
context
.sync_qr_code_token_deletion(invitenumber, authcode)
.await?;
}
Qr::WithdrawVerifyGroup {
grpid,
invitenumber,
authcode,
..
} => {
token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
token::delete(context, token::Namespace::Auth, &authcode).await?;
token::delete(context, &grpid).await?;
context
.sync_qr_code_token_deletion(invitenumber, authcode)
.await?;

View File

@@ -2,7 +2,7 @@ use super::*;
use crate::chat::{ProtectionStatus, create_group_chat};
use crate::config::Config;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{TestContext, TestContextManager};
use crate::test_utils::{TestContext, TestContextManager, sync};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_http() -> Result<()> {
@@ -509,6 +509,77 @@ async fn test_withdraw_verifygroup() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_multidevice() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
// Alice creates two QR codes on the first device:
// group QR code and contact QR code.
let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?;
let chat2_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group 2").await?;
let contact_qr = get_securejoin_qr(alice, None).await?;
let group_qr = get_securejoin_qr(alice, Some(chat_id)).await?;
let group2_qr = get_securejoin_qr(alice, Some(chat2_id)).await?;
assert!(matches!(
check_qr(alice, &contact_qr).await?,
Qr::WithdrawVerifyContact { .. }
));
assert!(matches!(
check_qr(alice, &group_qr).await?,
Qr::WithdrawVerifyGroup { .. }
));
// Sync group QR codes.
sync(alice, alice2).await;
assert!(matches!(
check_qr(alice2, &group_qr).await?,
Qr::WithdrawVerifyGroup { .. }
));
assert!(matches!(
check_qr(alice2, &group2_qr).await?,
Qr::WithdrawVerifyGroup { .. }
));
// Alice creates a contact QR code on second device
// and withdraws it.
let contact_qr2 = get_securejoin_qr(alice2, None).await?;
set_config_from_qr(alice2, &contact_qr2).await?;
assert!(matches!(
check_qr(alice2, &contact_qr2).await?,
Qr::ReviveVerifyContact { .. }
));
// Alice also withdraws second group QR code on second device.
set_config_from_qr(alice2, &group2_qr).await?;
// Sync messages are sent from Alice's second device to first device.
sync(alice2, alice).await;
// Now first device has reset all contact QR codes
// and second group QR code,
// but first group QR code is still valid.
assert!(matches!(
check_qr(alice, &contact_qr2).await?,
Qr::ReviveVerifyContact { .. }
));
assert!(matches!(
check_qr(alice, &group_qr).await?,
Qr::WithdrawVerifyGroup { .. }
));
assert!(matches!(
check_qr(alice, &group2_qr).await?,
Qr::ReviveVerifyGroup { .. }
));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_and_apply_dclogin() -> Result<()> {
let ctx = TestContext::new().await;

View File

@@ -404,7 +404,7 @@ mod tests {
use crate::config::Config;
use crate::contact::{Contact, Origin};
use crate::download::DownloadState;
use crate::message::{MessageState, delete_msgs};
use crate::message::{MessageState, Viewtype, delete_msgs};
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::sql::housekeeping;
use crate::test_utils::E2EE_INFO_MSGS;
@@ -550,6 +550,46 @@ Here's my footer -- bob@example.net"
let reactions = get_msg_reactions(&alice, msg.id).await?;
assert_eq!(reactions.to_string(), "😀1");
// Alice receives a message with reaction to her message from Bob.
let msg_bob = receive_imf(
&alice,
"To: alice@example.org\n\
From: bob@example.net\n\
Date: Today, 29 February 2021 00:00:10 -800\n\
Message-ID: 56791@example.net\n\
In-Reply-To: 12345@example.org\n\
Mime-Version: 1.0\n\
Content-Type: multipart/mixed; boundary=\"YiEDa0DAkWCtVeE4\"\n\
Content-Disposition: inline\n\
\n\
--YiEDa0DAkWCtVeE4\n\
Content-Type: text/plain; charset=utf-8\n\
Content-Disposition: inline\n\
\n\
Reply + reaction\n\
\n\
--YiEDa0DAkWCtVeE4\n\
Content-Type: text/plain; charset=utf-8\n\
Content-Disposition: reaction\n\
\n\
\u{1F44D}\n\
\n\
--YiEDa0DAkWCtVeE4--"
.as_bytes(),
false,
)
.await?
.unwrap();
let msg_bob = Message::load_from_db(&alice, msg_bob.msg_ids[0]).await?;
assert_eq!(msg_bob.from_id, bob_id);
assert_eq!(msg_bob.chat_id, msg.chat_id);
assert_eq!(msg_bob.viewtype, Viewtype::Text);
assert_eq!(msg_bob.state, MessageState::InFresh);
assert_eq!(msg_bob.hidden, false);
assert_eq!(msg_bob.text, "Reply + reaction");
let reactions = get_msg_reactions(&alice, msg.id).await?;
assert_eq!(reactions.to_string(), "👍1");
Ok(())
}

View File

@@ -1,6 +1,6 @@
//! Internet Message Format reception pipeline.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashSet};
use std::iter;
use std::sync::LazyLock;
@@ -15,10 +15,10 @@ use num_traits::FromPrimitive;
use regex::Regex;
use crate::chat::{
self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, remove_from_chat_contacts_table,
self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, save_broadcast_shared_secret,
};
use crate::config::Config;
use crate::constants::{Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
use crate::contact::{Contact, ContactId, Origin, mark_contact_id_as_verified};
use crate::context::Context;
use crate::debug_logging::maybe_set_logging_xdc_inner;
@@ -27,15 +27,15 @@ use crate::ephemeral::{Timer as EphemeralTimer, stock_ephemeral_timer_changed};
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table};
use crate::key::self_fingerprint_opt;
use crate::key::{DcKey, Fingerprint, SignedPublicKey};
use crate::key::{DcKey, Fingerprint};
use crate::key::{self_fingerprint, self_fingerprint_opt};
use crate::log::LogExt;
use crate::log::{info, warn};
use crate::logged_debug_assert;
use crate::message::{
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists,
};
use crate::mimeparser::{AvatarAction, MimeMessage, SystemMessage, parse_message_ids};
use crate::mimeparser::{AvatarAction, GossipedKey, MimeMessage, SystemMessage, parse_message_ids};
use crate::param::{Param, Params};
use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub};
use crate::reaction::{Reaction, set_msg_reaction};
@@ -44,7 +44,10 @@ use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on
use crate::simplify;
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{self, buf_compress, remove_subject_prefix};
use crate::tools::{
self, buf_compress, create_broadcast_shared_secret, remove_subject_prefix,
validate_broadcast_shared_secret,
};
use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location};
use crate::{contact, imap};
@@ -297,18 +300,16 @@ async fn get_to_and_past_contact_ids(
past_member_fingerprints = &[];
}
let pgp_to_ids = add_or_lookup_key_contacts_by_address_list(
context,
&mime_parser.recipients,
&mime_parser.gossiped_keys,
to_member_fingerprints,
Origin::Hidden,
)
.await?;
match chat_assignment {
ChatAssignment::GroupChat { .. } => {
to_ids = pgp_to_ids;
to_ids = add_or_lookup_key_contacts(
context,
&mime_parser.recipients,
&mime_parser.gossiped_keys,
to_member_fingerprints,
Origin::Hidden,
)
.await?;
if let Some(chat_id) = chat_id {
past_ids = lookup_key_contacts_by_address_list(
@@ -319,7 +320,7 @@ async fn get_to_and_past_contact_ids(
)
.await?;
} else {
past_ids = add_or_lookup_key_contacts_by_address_list(
past_ids = add_or_lookup_key_contacts(
context,
&mime_parser.past_members,
&mime_parser.gossiped_keys,
@@ -336,7 +337,14 @@ async fn get_to_and_past_contact_ids(
ChatAssignment::ExistingChat { chat_id, .. } => {
let chat = Chat::load_from_db(context, *chat_id).await?;
if chat.is_encrypted(context).await? {
to_ids = pgp_to_ids;
to_ids = add_or_lookup_key_contacts(
context,
&mime_parser.recipients,
&mime_parser.gossiped_keys,
to_member_fingerprints,
Origin::Hidden,
)
.await?;
past_ids = lookup_key_contacts_by_address_list(
context,
&mime_parser.past_members,
@@ -388,6 +396,14 @@ async fn get_to_and_past_contact_ids(
.await?;
}
ChatAssignment::OneOneChat => {
let pgp_to_ids = add_or_lookup_key_contacts(
context,
&mime_parser.recipients,
&mime_parser.gossiped_keys,
to_member_fingerprints,
Origin::Hidden,
)
.await?;
if pgp_to_ids
.first()
.is_some_and(|contact_id| contact_id.is_some())
@@ -429,7 +445,7 @@ async fn get_to_and_past_contact_ids(
context,
&mime_parser.recipients,
to_member_fingerprints,
chat_id,
None,
)
.await?
}
@@ -617,8 +633,7 @@ pub(crate) async fn receive_imf_inner(
return Ok(None);
};
let prevent_rename = (mime_parser.is_mailinglist_message() && !mime_parser.was_encrypted())
|| mime_parser.get_header(HeaderDef::Sender).is_some();
let prevent_rename = should_prevent_rename(&mime_parser);
// get From: (it can be an address list!) and check if it is known (for known From:'s we add
// the other To:/Cc: in the 3rd pass)
@@ -751,7 +766,6 @@ pub(crate) async fn receive_imf_inner(
let show_emails = ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?)
.unwrap_or_default();
let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction);
let allow_creation = if mime_parser.decrypting_failed {
false
} else if mime_parser.is_system_message != SystemMessage::AutocryptSetupMessage
@@ -765,7 +779,7 @@ pub(crate) async fn receive_imf_inner(
ShowEmails::All => true,
}
} else {
!is_reaction
!mime_parser.parts.iter().all(|part| part.is_reaction)
};
let to_id = if mime_parser.incoming {
@@ -824,7 +838,7 @@ pub(crate) async fn receive_imf_inner(
context
.sql
.transaction(move |transaction| {
let fingerprint = gossiped_key.dc_fingerprint().hex();
let fingerprint = gossiped_key.public_key.dc_fingerprint().hex();
transaction.execute(
"INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp)
VALUES (?, ?, ?)
@@ -989,12 +1003,18 @@ pub(crate) async fn receive_imf_inner(
}
}
if received_msg.hidden {
if mime_parser.is_call() {
context
.handle_call_msg(insert_msg_id, &mime_parser, from_id)
.await?;
} else if received_msg.hidden {
// No need to emit an event about the changed message
} else if let Some(replace_chat_id) = replace_chat_id {
context.emit_msgs_changed_without_msg_id(replace_chat_id);
} else if !chat_id.is_trash() {
let fresh = received_msg.state == MessageState::InFresh;
let fresh = received_msg.state == MessageState::InFresh
&& mime_parser.is_system_message != SystemMessage::CallAccepted
&& mime_parser.is_system_message != SystemMessage::CallEnded;
for msg_id in &received_msg.msg_ids {
chat_id.emit_msg_event(context, *msg_id, mime_parser.incoming && fresh);
}
@@ -1138,6 +1158,11 @@ async fn decide_chat_assignment(
{
info!(context, "Chat edit/delete/iroh/sync message (TRASH).");
true
} else if mime_parser.is_system_message == SystemMessage::CallAccepted
|| mime_parser.is_system_message == SystemMessage::CallEnded
{
info!(context, "Call state changed (TRASH).");
true
} else if mime_parser.decrypting_failed && !mime_parser.incoming {
// Outgoing undecryptable message.
let last_time = context
@@ -1206,17 +1231,21 @@ async fn decide_chat_assignment(
//
// The chat may not exist yet, i.e. there may be
// no database row and ChatId yet.
let mut num_recipients = mime_parser.recipients.len();
if from_id != ContactId::SELF {
let mut has_self_addr = false;
for recipient in &mime_parser.recipients {
if context.is_self_addr(&recipient.addr).await? {
has_self_addr = true;
}
let mut num_recipients = 0;
let mut has_self_addr = false;
for recipient in &mime_parser.recipients {
if addr_cmp(&recipient.addr, &mime_parser.from.addr) {
continue;
}
if !has_self_addr {
num_recipients += 1;
if context.is_self_addr(&recipient.addr).await? {
has_self_addr = true;
}
num_recipients += 1;
}
if from_id != ContactId::SELF && !has_self_addr {
num_recipients += 1;
}
let chat_assignment = if should_trash {
@@ -1259,11 +1288,15 @@ async fn decide_chat_assignment(
chat_id,
chat_id_blocked,
}
} else if mime_parser.get_header(HeaderDef::ChatGroupName).is_some() {
ChatAssignment::AdHocGroup
} else if num_recipients <= 1 {
ChatAssignment::OneOneChat
} else {
ChatAssignment::AdHocGroup
}
} else if mime_parser.get_header(HeaderDef::ChatGroupName).is_some() {
ChatAssignment::AdHocGroup
} else if num_recipients <= 1 {
ChatAssignment::OneOneChat
} else {
@@ -1359,6 +1392,7 @@ async fn do_chat_assignment(
create_or_lookup_mailinglist_or_broadcast(
context,
allow_creation,
create_blocked,
mailinglist_header,
from_id,
mime_parser,
@@ -1384,7 +1418,6 @@ async fn do_chat_assignment(
context,
mime_parser,
to_ids,
from_id,
allow_creation || test_normal_chat.is_some(),
create_blocked,
is_partial_download.is_some(),
@@ -1461,19 +1494,16 @@ async fn do_chat_assignment(
chat.typ == Chattype::Single,
"Chat {chat_id} is not Single",
);
let mut new_protection = match verified_encryption {
let new_protection = match verified_encryption {
VerifiedEncryption::Verified => ProtectionStatus::Protected,
VerifiedEncryption::NotVerified(_) => ProtectionStatus::Unprotected,
};
if chat.protected != ProtectionStatus::Unprotected
&& new_protection == ProtectionStatus::Unprotected
// `chat.protected` must be maintained regardless of the `Config::VerifiedOneOnOneChats`.
// That's why the config is checked here, and not above.
&& context.get_config_bool(Config::VerifiedOneOnOneChats).await?
{
new_protection = ProtectionStatus::ProtectionBroken;
}
ensure_and_debug_assert!(
chat.protected == ProtectionStatus::Unprotected
|| new_protection == ProtectionStatus::Protected,
"Chat {chat_id} can't downgrade to Unprotected",
);
if chat.protected != new_protection {
// The message itself will be sorted under the device message since the device
// message is `MessageState::InNoticed`, which means that all following
@@ -1547,7 +1577,9 @@ async fn do_chat_assignment(
} else {
let name =
compute_mailinglist_name(mailinglist_header, &listid, mime_parser);
chat::create_broadcast_ex(context, Nosync, listid, name).await?
let secret = create_broadcast_shared_secret();
chat::create_out_broadcast_ex(context, Nosync, listid, name, secret)
.await?
},
);
}
@@ -1557,7 +1589,6 @@ async fn do_chat_assignment(
context,
mime_parser,
to_ids,
from_id,
allow_creation,
Blocked::Not,
is_partial_download.is_some(),
@@ -1662,12 +1693,12 @@ async fn add_parts(
}
}
let mut chat = Chat::load_from_db(context, chat_id).await?;
if mime_parser.incoming && !chat_id.is_trash() {
// It can happen that the message is put into a chat
// but the From-address is not a member of this chat.
if !chat::is_contact_in_chat(context, chat_id, from_id).await? {
let chat = Chat::load_from_db(context, chat_id).await?;
// Mark the sender as overridden.
// The UI will prepend `~` to the sender's name,
// indicating that the sender is not part of the group.
@@ -1675,12 +1706,15 @@ async fn add_parts(
let name: &str = from.display_name.as_ref().unwrap_or(&from.addr);
for part in &mut mime_parser.parts {
part.param.set(Param::OverrideSenderDisplayname, name);
}
if chat.is_protected() {
// In protected chat, also mark the message with an error.
let s = stock_str::unknown_sender_for_chat(context).await;
part.error = Some(s);
if chat.typ == Chattype::InBroadcast {
let s = stock_str::error(context, "This message was not sent by the channel owner")
.await;
if let Some(part) = mime_parser.parts.first_mut() {
part.error = Some(format!("{s}:\n\"{}\"", part.msg));
}
mime_parser.replace_msg_by_error(&s);
}
}
}
@@ -1688,7 +1722,6 @@ async fn add_parts(
let is_location_kml = mime_parser.location_kml.is_some();
let is_mdn = !mime_parser.mdn_reports.is_empty();
let mut chat = Chat::load_from_db(context, chat_id).await?;
let mut group_changes = match chat.typ {
_ if chat.id.is_special() => GroupChangesInfo::default(),
Chattype::Single => GroupChangesInfo::default(),
@@ -1838,6 +1871,8 @@ async fn add_parts(
{
Some(stock_str::msg_location_enabled_by(context, from_id).await)
} else if mime_parser.is_system_message == SystemMessage::EphemeralTimerChanged {
let better_msg = stock_ephemeral_timer_changed(context, ephemeral_timer, from_id).await;
// Do not delete the system message itself.
//
// This prevents confusion when timer is changed
@@ -1846,31 +1881,12 @@ async fn add_parts(
// week is left.
ephemeral_timer = EphemeralTimer::Disabled;
Some(stock_ephemeral_timer_changed(context, ephemeral_timer, from_id).await)
Some(better_msg)
} else {
None
};
// if a chat is protected and the message is fully downloaded, check additional properties
if !chat_id.is_special() && is_partial_download.is_none() {
let chat = Chat::load_from_db(context, chat_id).await?;
// For outgoing emails in the 1:1 chat we have an exception that
// they are allowed to be unencrypted:
// 1. They can't be an attack (they are outgoing, not incoming)
// 2. Probably the unencryptedness is just a temporary state, after all
// the user obviously still uses DC
// -> Showing info messages every time would be a lot of noise
// 3. The info messages that are shown to the user ("Your chat partner
// likely reinstalled DC" or similar) would be wrong.
if chat.is_protected() && (mime_parser.incoming || chat.typ != Chattype::Single) {
if let VerifiedEncryption::NotVerified(err) = verified_encryption {
warn!(context, "Verification problem: {err:#}.");
let s = format!("{err}. See 'Info' for more details");
mime_parser.replace_msg_by_error(&s);
}
}
}
drop(chat); // Avoid using stale `chat` object.
let sort_timestamp = tweak_sort_timestamp(
context,
@@ -1983,10 +1999,28 @@ async fn add_parts(
handle_edit_delete(context, mime_parser, from_id).await?;
let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction);
let hidden = is_reaction;
if mime_parser.is_system_message == SystemMessage::CallAccepted
|| mime_parser.is_system_message == SystemMessage::CallEnded
{
if let Some(field) = mime_parser.get_header(HeaderDef::InReplyTo) {
if let Some(call) =
message::get_by_rfc724_mids(context, &parse_message_ids(field)).await?
{
context
.handle_call_msg(call.get_id(), mime_parser, from_id)
.await?;
} else {
warn!(context, "Call: Cannot load parent.")
}
} else {
warn!(context, "Call: Not a reply.")
}
}
let hidden = mime_parser.parts.iter().all(|part| part.is_reaction);
let mut parts = mime_parser.parts.iter().peekable();
while let Some(part) = parts.next() {
let hidden = part.is_reaction;
if part.is_reaction {
let reaction_str = simplify::remove_footers(part.msg.as_str());
let is_incoming_fresh = mime_parser.incoming && !seen;
@@ -2149,7 +2183,7 @@ RETURNING id
created_db_entries.push(row_id);
}
// check all parts whether they contain a new logging webxdc
// Maybe set logging xdc and add gossip topics for webxdcs.
for (part, msg_id) in mime_parser.parts.iter().zip(&created_db_entries) {
// check if any part contains a webxdc topic id
if part.typ == Viewtype::Webxdc {
@@ -2452,7 +2486,6 @@ async fn lookup_or_create_adhoc_group(
context: &Context,
mime_parser: &MimeMessage,
to_ids: &[Option<ContactId>],
from_id: ContactId,
allow_creation: bool,
create_blocked: Blocked,
is_partial_download: bool,
@@ -2475,10 +2508,29 @@ async fn lookup_or_create_adhoc_group(
return Ok(None);
}
// Lookup address-contact by the From address.
let fingerprint = None;
let find_key_contact_by_addr = false;
let prevent_rename = should_prevent_rename(mime_parser);
let (from_id, _from_id_blocked, _incoming_origin) = from_field_to_contact_id(
context,
&mime_parser.from,
fingerprint,
prevent_rename,
find_key_contact_by_addr,
)
.await?
.context("Cannot lookup address-contact by the From field")?;
let grpname = mime_parser
.get_subject()
.map(|s| remove_subject_prefix(&s))
.unwrap_or_else(|| "👥📧".to_string());
.get_header(HeaderDef::ChatGroupName)
.map(|s| s.to_string())
.unwrap_or_else(|| {
mime_parser
.get_subject()
.map(|s| remove_subject_prefix(&s))
.unwrap_or_else(|| "👥📧".to_string())
});
let to_ids: Vec<ContactId> = to_ids.iter().filter_map(|x| *x).collect();
let mut contact_ids = Vec::with_capacity(to_ids.len() + 1);
contact_ids.extend(&to_ids);
@@ -2843,20 +2895,13 @@ async fn apply_group_changes(
let is_from_in_chat =
!chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id);
if mime_parser.get_header(HeaderDef::ChatVerified).is_some() {
if mime_parser.get_header(HeaderDef::ChatVerified).is_some() && !chat.is_protected() {
if let VerifiedEncryption::NotVerified(err) = verified_encryption {
if chat.is_protected() {
warn!(context, "Verification problem: {err:#}.");
let s = format!("{err}. See 'Info' for more details");
mime_parser.replace_msg_by_error(&s);
} else {
warn!(
context,
"Not marking chat {} as protected due to verification problem: {err:#}.",
chat.id
);
}
} else if !chat.is_protected() {
warn!(
context,
"Not marking chat {} as protected due to verification problem: {err:#}.", chat.id,
);
} else {
chat.id
.set_protection(
context,
@@ -2869,15 +2914,13 @@ async fn apply_group_changes(
}
if let Some(removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
// TODO: if address "alice@example.org" is a member of the group twice,
// with old and new key,
// and someone (maybe Alice's new contact) just removed Alice's old contact,
// we may lookup the wrong contact because we only look up by the address.
// The result is that info message may contain the new Alice's display name
// rather than old display name.
// This could be fixed by looking up the contact with the highest
// `remove_timestamp` after applying Chat-Group-Member-Timestamps.
removed_id = lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?;
if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) {
removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?;
} else {
// Removal message sent by a legacy Delta Chat client.
removed_id =
lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?;
}
if let Some(id) = removed_id {
better_msg = if id == from_id {
silent = true;
@@ -2894,9 +2937,11 @@ async fn apply_group_changes(
// we may lookup the wrong contact.
// This could be fixed by looking up the contact with
// highest `add_timestamp` to disambiguate.
// Alternatively, this can be fixed by a header ChatGroupMemberAddedFpr,
// just like we have ChatGroupMemberRemovedFpr.
// The result of the error is that info message
// may contain display name of the wrong contact.
let fingerprint = key.dc_fingerprint().hex();
let fingerprint = key.public_key.dc_fingerprint().hex();
if let Some(contact_id) =
lookup_key_contact_by_fingerprint(context, &fingerprint).await?
{
@@ -3035,9 +3080,7 @@ async fn apply_group_changes(
if let Some(added_id) = added_id {
if !added_ids.remove(&added_id) && !self_added {
// No-op "Member added" message.
//
// Trash it.
info!(context, "No-op 'Member added' message (TRASH)");
better_msg = Some(String::new());
}
}
@@ -3233,6 +3276,7 @@ fn mailinglist_header_listid(list_id_header: &str) -> Result<String> {
async fn create_or_lookup_mailinglist_or_broadcast(
context: &Context,
allow_creation: bool,
create_blocked: Blocked,
list_id_header: &str,
from_id: ContactId,
mime_parser: &MimeMessage,
@@ -3265,18 +3309,12 @@ async fn create_or_lookup_mailinglist_or_broadcast(
p.to_string()
});
let is_bot = context.get_config_bool(Config::Bot).await?;
let blocked = if is_bot {
Blocked::Not
} else {
Blocked::Request
};
let chat_id = ChatId::create_multiuser_record(
context,
chattype,
&listid,
name,
blocked,
create_blocked,
ProtectionStatus::Unprotected,
param,
mime_parser.timestamp_sent,
@@ -3289,13 +3327,6 @@ async fn create_or_lookup_mailinglist_or_broadcast(
)
})?;
chat::add_to_chat_contacts_table(
context,
mime_parser.timestamp_sent,
chat_id,
&[ContactId::SELF],
)
.await?;
if chattype == Chattype::InBroadcast {
chat::add_to_chat_contacts_table(
context,
@@ -3305,7 +3336,12 @@ async fn create_or_lookup_mailinglist_or_broadcast(
)
.await?;
}
Ok(Some((chat_id, blocked)))
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(Some((chat_id, create_blocked)))
} else {
info!(context, "Creating list forbidden by caller.");
Ok(None)
@@ -3458,19 +3494,53 @@ async fn apply_out_broadcast_changes(
) -> Result<GroupChangesInfo> {
ensure!(chat.typ == Chattype::OutBroadcast);
if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
// The sender of the message left the broadcast channel
remove_from_chat_contacts_table(context, chat.id, from_id).await?;
let mut send_event_chat_modified = false;
let mut better_msg = None;
return Ok(GroupChangesInfo {
better_msg: Some("".to_string()),
added_removed_id: None,
silent: true,
extra_msgs: vec![],
});
apply_chat_name_and_avatar_changes(
context,
mime_parser,
from_id,
chat,
&mut send_event_chat_modified,
&mut better_msg,
)
.await?;
if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) {
send_event_chat_modified = true;
let removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?;
if removed_id == Some(from_id) {
// The sender of the message left the broadcast channel
// Silently remove them without notifying the user
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, from_id).await?;
info!(context, "Broadcast leave message (TRASH)");
better_msg = Some("".to_string());
} else if from_id == ContactId::SELF {
if let Some(removed_id) = removed_id {
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, removed_id)
.await?;
better_msg.get_or_insert(
stock_str::msg_del_member_local(context, removed_id, ContactId::SELF).await,
);
}
}
}
// No need to check for ChatGroupMemberAdded:
// The only way to add a member is by having them scan a QR code.
// All devices will receive Bob's vb-request-with-auth message and add him to the channel.
Ok(GroupChangesInfo::default())
if send_event_chat_modified {
context.emit_event(EventType::ChatModified(chat.id));
chatlist_events::emit_chatlist_item_changed(context, chat.id);
}
Ok(GroupChangesInfo {
better_msg,
added_removed_id: None,
silent: false,
extra_msgs: vec![],
})
}
async fn apply_in_broadcast_changes(
@@ -3481,6 +3551,16 @@ async fn apply_in_broadcast_changes(
) -> Result<GroupChangesInfo> {
ensure!(chat.typ == Chattype::InBroadcast);
if let Some(part) = mime_parser.parts.first() {
if let Some(error) = &part.error {
warn!(
context,
"Not applying broadcast changes from message with error: {error}"
);
return Ok(GroupChangesInfo::default());
}
}
let mut send_event_chat_modified = false;
let mut better_msg = None;
@@ -3494,12 +3574,61 @@ async fn apply_in_broadcast_changes(
)
.await?;
if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
// The only member added/removed message that is ever sent is "I left.",
// so, this is the only case we need to handle here
if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) {
if context.is_self_addr(added_addr).await? {
let msg;
if chat.is_self_in_chat(context).await? {
// Self is already in the chat.
// Probably Alice has two devices and her second device added us again;
// just hide the message.
info!(context, "No-op broadcast 'Member added' message (TRASH)");
msg = "".to_string();
} else {
chat.id
.add_encrypted_msg(context, mime_parser.timestamp_sent)
.await?;
msg = stock_str::msg_add_member_local(context, ContactId::SELF, from_id).await;
}
better_msg.get_or_insert(msg);
send_event_chat_modified = true;
}
}
if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) {
// We are not supposed to receive a notification when someone else than self is removed:
ensure!(removed_fpr == self_fingerprint(context).await?);
if from_id == ContactId::SELF {
better_msg
.get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await);
} else {
better_msg.get_or_insert(
stock_str::msg_del_member_local(context, ContactId::SELF, from_id).await,
);
}
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, ContactId::SELF)
.await?;
send_event_chat_modified = true;
} else if !chat.is_self_in_chat(context).await? {
// Apparently, self is in the chat now, because we're receiving messages
chat::add_to_chat_contacts_table(
context,
mime_parser.timestamp_sent,
chat.id,
&[ContactId::SELF],
)
.await?;
send_event_chat_modified = true;
}
if let Some(secret) = mime_parser.get_header(HeaderDef::ChatBroadcastSecret) {
if validate_broadcast_shared_secret(secret) {
save_broadcast_shared_secret(context, chat.id, secret).await?;
} else {
warn!(context, "Not saving invalid broadcast secret");
}
}
@@ -3638,15 +3767,34 @@ async fn mark_recipients_as_verified(
to_ids: &[Option<ContactId>],
mimeparser: &MimeMessage,
) -> Result<()> {
let verifier_id = Some(from_id).filter(|&id| id != ContactId::SELF);
for gossiped_key in mimeparser
.gossiped_keys
.values()
.filter(|gossiped_key| gossiped_key.verified)
{
let fingerprint = gossiped_key.public_key.dc_fingerprint().hex();
let Some(to_id) = lookup_key_contact_by_fingerprint(context, &fingerprint).await? else {
continue;
};
if to_id == ContactId::SELF || to_id == from_id {
continue;
}
mark_contact_id_as_verified(context, to_id, verifier_id).await?;
ChatId::set_protection_for_contact(context, to_id, mimeparser.timestamp_sent).await?;
}
if mimeparser.get_header(HeaderDef::ChatVerified).is_none() {
return Ok(());
}
for to_id in to_ids.iter().filter_map(|&x| x) {
if to_id == ContactId::SELF {
if to_id == ContactId::SELF || to_id == from_id {
continue;
}
mark_contact_id_as_verified(context, to_id, from_id).await?;
mark_contact_id_as_verified(context, to_id, verifier_id).await?;
ChatId::set_protection_for_contact(context, to_id, mimeparser.timestamp_sent).await?;
}
@@ -3730,10 +3878,10 @@ async fn add_or_lookup_contacts_by_address_list(
}
/// Looks up contact IDs from the database given the list of recipients.
async fn add_or_lookup_key_contacts_by_address_list(
async fn add_or_lookup_key_contacts(
context: &Context,
address_list: &[SingleInfo],
gossiped_keys: &HashMap<String, SignedPublicKey>,
gossiped_keys: &BTreeMap<String, GossipedKey>,
fingerprints: &[Fingerprint],
origin: Origin,
) -> Result<Vec<Option<ContactId>>> {
@@ -3749,7 +3897,10 @@ async fn add_or_lookup_key_contacts_by_address_list(
// Iterator has not ran out of fingerprints yet.
fp.hex()
} else if let Some(key) = gossiped_keys.get(addr) {
key.dc_fingerprint().hex()
key.public_key.dc_fingerprint().hex()
} else if context.is_self_addr(addr).await? {
contact_ids.push(Some(ContactId::SELF));
continue;
} else {
contact_ids.push(None);
continue;
@@ -3778,13 +3929,16 @@ async fn add_or_lookup_key_contacts_by_address_list(
/// Looks up a key-contact by email address.
///
/// If provided, `chat_id` must be an encrypted chat ID that has key-contacts inside.
/// Otherwise the function searches in all contacts, returning the recently seen one.
/// Otherwise the function searches in all contacts, preferring accepted and most recently seen ones.
async fn lookup_key_contact_by_address(
context: &Context,
addr: &str,
chat_id: Option<ChatId>,
) -> Result<Option<ContactId>> {
if context.is_self_addr(addr).await? {
if chat_id.is_none() {
return Ok(Some(ContactId::SELF));
}
let is_self_in_chat = context
.sql
.exists(
@@ -3821,11 +3975,26 @@ async fn lookup_key_contact_by_address(
.sql
.query_row_optional(
"SELECT id FROM contacts
WHERE contacts.addr=?1
WHERE addr=?
AND fingerprint<>''
ORDER BY last_seen DESC, id DESC
ORDER BY
(
SELECT COUNT(*) FROM chats c
INNER JOIN chats_contacts cc
ON c.id=cc.chat_id
WHERE c.type=?
AND c.id>?
AND c.blocked=?
AND cc.contact_id=contacts.id
) DESC,
last_seen DESC, id DESC
",
(addr,),
(
addr,
Chattype::Single,
constants::DC_CHAT_ID_LAST_SPECIAL,
Blocked::Not,
),
|row| {
let contact_id: ContactId = row.get(0)?;
Ok(contact_id)
@@ -3933,5 +4102,12 @@ async fn lookup_key_contacts_by_address_list(
Ok(contact_ids)
}
/// Returns true if the message should not result in renaming
/// of the sender contact.
fn should_prevent_rename(mime_parser: &MimeMessage) -> bool {
(mime_parser.is_mailinglist_message() && !mime_parser.was_encrypted())
|| mime_parser.get_header(HeaderDef::Sender).is_some()
}
#[cfg(test)]
mod receive_imf_tests;

View File

@@ -881,7 +881,7 @@ async fn test_github_mailing_list() -> Result<()> {
Some("reply+elernshsetushoyseshetihseusaferuhsedtisneu@reply.github.com")
);
assert_eq!(chat.name, "deltachat/deltachat-core-rust");
assert_eq!(chat::get_chat_contacts(&t.ctx, chat_id).await?.len(), 1);
assert_eq!(chat::get_chat_contacts(&t.ctx, chat_id).await?.len(), 0);
receive_imf(&t.ctx, GH_MAILINGLIST2.as_bytes(), false).await?;
@@ -991,6 +991,7 @@ async fn test_other_device_writes_to_mailinglist() -> Result<()> {
.await?
.unwrap();
let list_post_contact = Contact::get_by_id(&t, list_post_contact_id).await?;
assert_eq!(list_post_contact.is_key_contact(), false);
assert_eq!(
list_post_contact.param.get(Param::ListId).unwrap(),
"delta.codespeak.net"
@@ -1453,6 +1454,7 @@ async fn test_apply_mailinglist_changes_assigned_by_reply() {
.unwrap()
.unwrap();
let contact = Contact::get_by_id(&t, contact_id).await.unwrap();
assert_eq!(contact.is_key_contact(), false);
assert_eq!(
contact.param.get(Param::ListId).unwrap(),
"deltachat-core-rust.deltachat.github.com"
@@ -3050,6 +3052,39 @@ async fn test_auto_accept_protected_group_for_bots() -> Result<()> {
Ok(())
}
/// Regression test for a bug where receive_imf() failed
/// if the sender of a verification-gossiping message
/// also put itself into the To header.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_verification_gossip() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
mark_as_verified(alice, bob).await;
mark_as_verified(bob, alice).await;
// This is message sent by Alice with verified encryption
// that gossips Fiona's verification,
// and for some reason, Alice also put herself into the To: header.
let imf_raw =
include_bytes!("../../test-data/message/verification-gossip-also-sent-to-from.eml");
// The regression test is that receive_imf() doesn't panic:
let msg = receive_imf(bob, imf_raw, false).await?.unwrap();
let msg = Message::load_from_db(bob, msg.msg_ids[0]).await?;
assert_eq!(msg.text, "Hello!");
assert!(
bob.add_or_lookup_contact(fiona)
.await
.is_verified(bob)
.await?
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_bot_accepts_another_group_after_qr_scan() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -3281,6 +3316,31 @@ async fn test_thunderbird_autocrypt() -> Result<()> {
Ok(())
}
/// Tests that a message without an Autocrypt header is assigned to the key-contact
/// by using the signature Issuer Fingerprint.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_issuer_fingerprint() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
let raw = include_bytes!("../../test-data/message/encrypted-signed.eml");
let received_msg = receive_imf(bob, raw, false).await?.unwrap();
assert_eq!(received_msg.msg_ids.len(), 1);
let msg_id = received_msg.msg_ids[0];
let message = Message::load_from_db(bob, msg_id).await?;
assert!(message.get_showpadlock());
let from_id = message.from_id;
assert_eq!(from_id, alice_contact_id);
Ok(())
}
/// Tests reception of a message from Thunderbird with attached key.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_prefer_encrypt_mutual_if_encrypted() -> Result<()> {
@@ -3649,10 +3709,13 @@ async fn test_mua_user_adds_recipient_to_single_chat() -> Result<()> {
chat::get_chat_contacts(&alice, group_chat.id).await?.len(),
4
);
let fiona = Contact::lookup_id_by_addr(&alice, "fiona@example.net", Origin::IncomingTo)
.await?
.unwrap();
assert!(chat::is_contact_in_chat(&alice, group_chat.id, fiona).await?);
let fiona_contact_id =
Contact::lookup_id_by_addr(&alice, "fiona@example.net", Origin::IncomingTo)
.await?
.unwrap();
assert!(chat::is_contact_in_chat(&alice, group_chat.id, fiona_contact_id).await?);
let fiona_contact = Contact::get_by_id(&alice, fiona_contact_id).await?;
assert_eq!(fiona_contact.is_key_contact(), false);
Ok(())
}
@@ -5053,6 +5116,58 @@ async fn test_two_group_securejoins() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unverified_member_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let alice_chat_id =
chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
let fiona_chat_id = fiona.get_last_msg().await.chat_id;
let fiona_sent_msg = fiona.send_text(fiona_chat_id, "Hi").await;
// The message is by non-verified member,
// but the checks have been removed
// and the message should be downloaded as usual.
let bob_msg = bob.recv_msg(&fiona_sent_msg).await;
assert_eq!(bob_msg.download_state, DownloadState::Done);
assert_eq!(bob_msg.text, "Hi");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_reverify_by_self_on_outgoing_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let a0 = &tcm.alice().await;
let a1 = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let bob_chat_id = chat::create_group_chat(bob, ProtectionStatus::Protected, "Group").await?;
let qr = get_securejoin_qr(bob, Some(bob_chat_id)).await?;
tcm.exec_securejoin_qr(fiona, bob, &qr).await;
tcm.exec_securejoin_qr(a0, bob, &qr).await;
tcm.exec_securejoin_qr(a1, bob, &qr).await;
let a0_chat_id = a0.get_last_msg().await.chat_id;
let a0_sent_msg = a0.send_text(a0_chat_id, "Hi").await;
a1.recv_msg(&a0_sent_msg).await;
let a1_bob_id = a1.add_or_lookup_contact_id(bob).await;
let a1_fiona = a1.add_or_lookup_contact(fiona).await;
assert_eq!(
a1_fiona.get_verifier_id(a1).await?.unwrap().unwrap(),
a1_bob_id
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sanitize_filename_in_received() -> Result<()> {
let alice = &TestContext::new_alice().await;
@@ -5269,3 +5384,176 @@ async fn test_outgoing_unencrypted_chat_assignment() {
let chat = alice.create_email_chat(bob).await;
assert_eq!(received.chat_id, chat.id);
}
/// Tests Bob receiving a message from Alice
/// in a new group she just created
/// with only Alice and Bob.
///
/// The message has no Autocrypt-Gossip
/// headers and no Chat-Group-Member-Fpr header.
/// Such messages were created by core 1.159.5
/// when Alice has bcc_self disabled
/// as Chat-Group-Member-Fpr header did not exist
/// yet and Autocrypt-Gossip is not sent
/// as there is only one recipient
/// (Bob, and no additional Alice devices).
///
/// Bob should recognize self as being
/// a member of the group by just the e-mail address.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_group_introduction_no_gossip() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let received = receive_imf(
bob,
include_bytes!("../../test-data/message/group-introduction-no-gossip.eml"),
false,
)
.await
.unwrap()
.unwrap();
let msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap())
.await
.unwrap();
assert_eq!(msg.text, "I created a group");
let chat = Chat::load_from_db(bob, msg.chat_id).await.unwrap();
assert_eq!(chat.typ, Chattype::Group);
assert_eq!(chat.blocked, Blocked::Request);
assert_eq!(chat.name, "Group!");
assert!(chat.is_encrypted(bob).await.unwrap());
let contacts = get_chat_contacts(bob, chat.id).await?;
assert_eq!(contacts.len(), 2);
assert!(chat.is_self_in_chat(bob).await?);
Ok(())
}
/// Tests reception of an encrypted group message
/// without Chat-Group-ID.
///
/// The message should be displayed as
/// encrypted and have key-contact `from_id`,
/// but all group members should be address-contacts.
///
/// Due to a bug in v2.10.0 this resulted
/// in creation of an ad hoc group with a key-contact.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_encrypted_adhoc_group_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// Bob receives encrypted message from Alice
// sent to multiple recipients,
// but without a group ID.
let received = receive_imf(
bob,
include_bytes!("../../test-data/message/encrypted-group-without-id.eml"),
false,
)
.await?
.unwrap();
let msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap())
.await
.unwrap();
let chat = Chat::load_from_db(bob, msg.chat_id).await.unwrap();
assert_eq!(chat.typ, Chattype::Group);
assert_eq!(chat.is_encrypted(bob).await?, false);
let contact_ids = get_chat_contacts(bob, chat.id).await?;
assert_eq!(contact_ids.len(), 3);
assert!(chat.is_self_in_chat(bob).await?);
// Since the group is unencrypted, all contacts have
// to be address-contacts.
for contact_id in contact_ids {
let contact = Contact::get_by_id(bob, contact_id).await?;
if contact_id != ContactId::SELF {
assert_eq!(contact.is_key_contact(), false);
}
}
// `from_id` of the message corresponds to key-contact of Alice
// even though the message is assigned to unencrypted chat.
let alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
assert_eq!(msg.from_id, alice_contact_id);
Ok(())
}
/// Tests that messages sent to unencrypted group
/// with only two members arrive in a group
/// and not in 1:1 chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_small_unencrypted_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = chat::create_group_ex(alice, None, "Unencrypted group").await?;
let alice_bob_id = alice.add_or_lookup_address_contact_id(bob).await;
add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?;
send_text_msg(alice, alice_chat_id, "Hello!".to_string()).await?;
let sent_msg = alice.pop_sent_msg().await;
let bob_chat_id = bob.recv_msg(&sent_msg).await.chat_id;
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.typ, Chattype::Group);
assert_eq!(bob_chat.is_encrypted(bob).await?, false);
bob_chat_id.accept(bob).await?;
send_text_msg(bob, bob_chat_id, "Hello back!".to_string()).await?;
let sent_msg = bob.pop_sent_msg().await;
let alice_rcvd_msg = alice.recv_msg(&sent_msg).await;
assert_eq!(alice_rcvd_msg.chat_id, alice_chat_id);
Ok(())
}
/// Tests that if the sender includes self
/// in the `To` field, we do not count
/// it as a third recipient in addition to ourselves
/// and the sender and do not create a group chat.
///
/// This is a regression test.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_bcc_not_a_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let received = receive_imf(
alice,
b"From: \"\"<foobar@example.org>\n\
To: <foobar@example.org>\n\
Subject: Hello, this is not a group\n\
Message-ID: <abcdef@example.org>\n\
Chat-Version: 1.0\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
false,
)
.await?
.unwrap();
let received_chat = Chat::load_from_db(alice, received.chat_id).await?;
assert_eq!(received_chat.typ, Chattype::Single);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lookup_key_contact_by_address_self() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let addr = &t.get_config(Config::Addr).await?.unwrap();
assert_eq!(
lookup_key_contact_by_address(t, addr, None).await?,
Some(ContactId::SELF)
);
Ok(())
}

View File

@@ -8,12 +8,12 @@ use async_channel::{self as channel, Receiver, Sender};
use futures::future::try_join_all;
use futures_lite::FutureExt;
use rand::Rng;
use tokio::sync::{RwLock, RwLockWriteGuard, oneshot};
use tokio::sync::{RwLock, oneshot};
use tokio::task;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use self::connectivity::ConnectivityStore;
pub(crate) use self::connectivity::ConnectivityStore;
use crate::config::{self, Config};
use crate::constants;
use crate::contact::{ContactId, RecentlySeenLoop};
@@ -53,32 +53,32 @@ impl SchedulerState {
}
/// Starts the scheduler if it is not yet started.
pub(crate) async fn start(&self, context: Context) {
pub(crate) async fn start(&self, context: &Context) {
let mut inner = self.inner.write().await;
match *inner {
InnerSchedulerState::Started(_) => (),
InnerSchedulerState::Stopped => Self::do_start(inner, context).await,
InnerSchedulerState::Stopped => Self::do_start(&mut inner, context).await,
InnerSchedulerState::Paused {
ref mut started, ..
} => *started = true,
}
context.update_connectivities(&inner);
}
/// Starts the scheduler if it is not yet started.
async fn do_start(mut inner: RwLockWriteGuard<'_, InnerSchedulerState>, context: Context) {
async fn do_start(inner: &mut InnerSchedulerState, context: &Context) {
info!(context, "starting IO");
// Notify message processing loop
// to allow processing old messages after restart.
context.new_msgs_notify.notify_one();
let ctx = context.clone();
match Scheduler::start(&context).await {
match Scheduler::start(context).await {
Ok(scheduler) => {
*inner = InnerSchedulerState::Started(scheduler);
context.emit_event(EventType::ConnectivityChanged);
}
Err(err) => error!(&ctx, "Failed to start IO: {:#}", err),
Err(err) => error!(context, "Failed to start IO: {:#}", err),
}
}
@@ -87,18 +87,19 @@ impl SchedulerState {
let mut inner = self.inner.write().await;
match *inner {
InnerSchedulerState::Started(_) => {
Self::do_stop(inner, context, InnerSchedulerState::Stopped).await
Self::do_stop(&mut inner, context, InnerSchedulerState::Stopped).await
}
InnerSchedulerState::Stopped => (),
InnerSchedulerState::Paused {
ref mut started, ..
} => *started = false,
}
context.update_connectivities(&inner);
}
/// Stops the scheduler if it is currently running.
async fn do_stop(
mut inner: RwLockWriteGuard<'_, InnerSchedulerState>,
inner: &mut InnerSchedulerState,
context: &Context,
new_state: InnerSchedulerState,
) {
@@ -122,7 +123,7 @@ impl SchedulerState {
debug_logging.loop_handle.abort();
debug_logging.loop_handle.await.ok();
}
let prev_state = std::mem::replace(&mut *inner, new_state);
let prev_state = std::mem::replace(inner, new_state);
context.emit_event(EventType::ConnectivityChanged);
match prev_state {
InnerSchedulerState::Started(scheduler) => scheduler.stop(context).await,
@@ -138,7 +139,7 @@ impl SchedulerState {
/// If in the meantime [`SchedulerState::start`] or [`SchedulerState::stop`] is called
/// resume will do the right thing and restore the scheduler to the state requested by
/// the last call.
pub(crate) async fn pause(&'_ self, context: Context) -> Result<IoPausedGuard> {
pub(crate) async fn pause(&'_ self, context: &Context) -> Result<IoPausedGuard> {
{
let mut inner = self.inner.write().await;
match *inner {
@@ -147,7 +148,7 @@ impl SchedulerState {
started: true,
pause_guards_count: NonZeroUsize::new(1).unwrap(),
};
Self::do_stop(inner, &context, new_state).await;
Self::do_stop(&mut inner, context, new_state).await;
}
InnerSchedulerState::Stopped => {
*inner = InnerSchedulerState::Paused {
@@ -164,9 +165,11 @@ impl SchedulerState {
.ok_or_else(|| Error::msg("Too many pause guards active"))?
}
}
context.update_connectivities(&inner);
}
let (tx, rx) = oneshot::channel();
let context = context.clone();
tokio::spawn(async move {
rx.await.ok();
let mut inner = context.scheduler.inner.write().await;
@@ -183,7 +186,7 @@ impl SchedulerState {
} => {
if *pause_guards_count == NonZeroUsize::new(1).unwrap() {
match *started {
true => SchedulerState::do_start(inner, context.clone()).await,
true => SchedulerState::do_start(&mut inner, &context).await,
false => *inner = InnerSchedulerState::Stopped,
}
} else {
@@ -193,6 +196,7 @@ impl SchedulerState {
}
}
}
context.update_connectivities(&inner);
});
Ok(IoPausedGuard { sender: Some(tx) })
}
@@ -202,7 +206,7 @@ impl SchedulerState {
info!(context, "restarting IO");
if self.is_running().await {
self.stop(context).await;
self.start(context.clone()).await;
self.start(context).await;
}
}
@@ -223,7 +227,7 @@ impl SchedulerState {
_ => return,
};
drop(inner);
connectivity::idle_interrupted(inbox, oboxes).await;
connectivity::idle_interrupted(inbox, oboxes);
}
/// Indicate that the network likely is lost.
@@ -240,7 +244,7 @@ impl SchedulerState {
_ => return,
};
drop(inner);
connectivity::maybe_network_lost(context, stores).await;
connectivity::maybe_network_lost(context, stores);
}
pub(crate) async fn interrupt_inbox(&self) {
@@ -288,7 +292,7 @@ impl SchedulerState {
}
#[derive(Debug, Default)]
enum InnerSchedulerState {
pub(crate) enum InnerSchedulerState {
Started(Scheduler),
#[default]
Stopped,
@@ -565,7 +569,7 @@ async fn fetch_idle(
// The folder is not configured.
// For example, this happens if the server does not have Sent folder
// but watching Sent folder is enabled.
connection.connectivity.set_not_configured(ctx).await;
connection.connectivity.set_not_configured(ctx);
connection.idle_interrupt_receiver.recv().await.ok();
bail!("Cannot fetch folder {folder_meaning} because it is not configured");
};
@@ -655,7 +659,7 @@ async fn fetch_idle(
.log_err(ctx)
.ok();
connection.connectivity.set_idle(ctx).await;
connection.connectivity.set_idle(ctx);
ctx.emit_event(EventType::ImapInboxIdle);
@@ -806,8 +810,8 @@ async fn smtp_loop(
// Fake Idle
info!(ctx, "SMTP fake idle started.");
match &connection.last_send_error {
None => connection.connectivity.set_idle(&ctx).await,
Some(err) => connection.connectivity.set_err(&ctx, err).await,
None => connection.connectivity.set_idle(&ctx),
Some(err) => connection.connectivity.set_err(&ctx, err),
}
// If send_smtp_messages() failed, we set a timeout for the fake-idle so that

View File

@@ -4,7 +4,6 @@ use std::{iter::once, ops::Deref, sync::Arc};
use anyhow::Result;
use humansize::{BINARY, format_size};
use tokio::sync::Mutex;
use crate::events::EventType;
use crate::imap::{FolderMeaning, scan_folders::get_watched_folder_configs};
@@ -160,52 +159,51 @@ impl DetailedConnectivity {
}
#[derive(Clone, Default)]
pub(crate) struct ConnectivityStore(Arc<Mutex<DetailedConnectivity>>);
pub(crate) struct ConnectivityStore(Arc<parking_lot::Mutex<DetailedConnectivity>>);
impl ConnectivityStore {
async fn set(&self, context: &Context, v: DetailedConnectivity) {
fn set(&self, context: &Context, v: DetailedConnectivity) {
{
*self.0.lock().await = v;
*self.0.lock() = v;
}
context.emit_event(EventType::ConnectivityChanged);
}
pub(crate) async fn set_err(&self, context: &Context, e: impl ToString) {
self.set(context, DetailedConnectivity::Error(e.to_string()))
.await;
pub(crate) fn set_err(&self, context: &Context, e: impl ToString) {
self.set(context, DetailedConnectivity::Error(e.to_string()));
}
pub(crate) async fn set_connecting(&self, context: &Context) {
self.set(context, DetailedConnectivity::Connecting).await;
pub(crate) fn set_connecting(&self, context: &Context) {
self.set(context, DetailedConnectivity::Connecting);
}
pub(crate) async fn set_working(&self, context: &Context) {
self.set(context, DetailedConnectivity::Working).await;
pub(crate) fn set_working(&self, context: &Context) {
self.set(context, DetailedConnectivity::Working);
}
pub(crate) async fn set_preparing(&self, context: &Context) {
self.set(context, DetailedConnectivity::Preparing).await;
pub(crate) fn set_preparing(&self, context: &Context) {
self.set(context, DetailedConnectivity::Preparing);
}
pub(crate) async fn set_not_configured(&self, context: &Context) {
self.set(context, DetailedConnectivity::NotConfigured).await;
pub(crate) fn set_not_configured(&self, context: &Context) {
self.set(context, DetailedConnectivity::NotConfigured);
}
pub(crate) async fn set_idle(&self, context: &Context) {
self.set(context, DetailedConnectivity::Idle).await;
pub(crate) fn set_idle(&self, context: &Context) {
self.set(context, DetailedConnectivity::Idle);
}
async fn get_detailed(&self) -> DetailedConnectivity {
self.0.lock().await.deref().clone()
fn get_detailed(&self) -> DetailedConnectivity {
self.0.lock().deref().clone()
}
async fn get_basic(&self) -> Option<Connectivity> {
self.0.lock().await.to_basic()
fn get_basic(&self) -> Option<Connectivity> {
self.0.lock().to_basic()
}
async fn get_all_work_done(&self) -> bool {
self.0.lock().await.all_work_done()
fn get_all_work_done(&self) -> bool {
self.0.lock().all_work_done()
}
}
/// Set all folder states to InterruptingIdle in case they were `Idle` before.
/// Called during `dc_maybe_network()` to make sure that `all_work_done()`
/// returns false immediately after `dc_maybe_network()`.
pub(crate) async fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec<ConnectivityStore>) {
let mut connectivity_lock = inbox.0.lock().await;
pub(crate) fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec<ConnectivityStore>) {
let mut connectivity_lock = inbox.0.lock();
// For the inbox, we also have to set the connectivity to InterruptingIdle if it was
// NotConfigured before: If all folders are NotConfigured, dc_get_connectivity()
// returns Connected. But after dc_maybe_network(), dc_get_connectivity() must not
@@ -219,7 +217,7 @@ pub(crate) async fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec<Conne
drop(connectivity_lock);
for state in oboxes {
let mut connectivity_lock = state.0.lock().await;
let mut connectivity_lock = state.0.lock();
if *connectivity_lock == DetailedConnectivity::Idle {
*connectivity_lock = DetailedConnectivity::InterruptingIdle;
}
@@ -231,9 +229,9 @@ pub(crate) async fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec<Conne
/// Set the connectivity to "Not connected" after a call to dc_maybe_network_lost().
/// If we did not do this, the connectivity would stay "Connected" for quite a long time
/// after `maybe_network_lost()` was called.
pub(crate) async fn maybe_network_lost(context: &Context, stores: Vec<ConnectivityStore>) {
pub(crate) fn maybe_network_lost(context: &Context, stores: Vec<ConnectivityStore>) {
for store in &stores {
let mut connectivity_lock = store.0.lock().await;
let mut connectivity_lock = store.0.lock();
if !matches!(
*connectivity_lock,
DetailedConnectivity::Uninitialized
@@ -248,7 +246,7 @@ pub(crate) async fn maybe_network_lost(context: &Context, stores: Vec<Connectivi
impl fmt::Debug for ConnectivityStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Ok(guard) = self.0.try_lock() {
if let Some(guard) = self.0.try_lock() {
write!(f, "ConnectivityStore {:?}", &*guard)
} else {
write!(f, "ConnectivityStore [LOCKED]")
@@ -271,27 +269,29 @@ impl Context {
/// e.g. in the title of the main screen.
///
/// If the connectivity changes, a DC_EVENT_CONNECTIVITY_CHANGED will be emitted.
pub async fn get_connectivity(&self) -> Connectivity {
let lock = self.scheduler.inner.read().await;
let stores: Vec<_> = match *lock {
InnerSchedulerState::Started(ref sched) => sched
.boxes()
.map(|b| b.conn_state.state.connectivity.clone())
.collect(),
_ => return Connectivity::NotConnected,
};
drop(lock);
pub fn get_connectivity(&self) -> Connectivity {
let stores = self.connectivities.lock().clone();
let mut connectivities = Vec::new();
for s in stores {
if let Some(connectivity) = s.get_basic().await {
if let Some(connectivity) = s.get_basic() {
connectivities.push(connectivity);
}
}
connectivities
.into_iter()
.min()
.unwrap_or(Connectivity::Connected)
.unwrap_or(Connectivity::NotConnected)
}
pub(crate) fn update_connectivities(&self, sched: &InnerSchedulerState) {
let stores: Vec<_> = match sched {
InnerSchedulerState::Started(sched) => sched
.boxes()
.map(|b| b.conn_state.state.connectivity.clone())
.collect(),
_ => Vec::new(),
};
*self.connectivities.lock() = stores;
}
/// Get an overview of the current connectivity, and possibly more statistics.
@@ -391,7 +391,7 @@ impl Context {
let f = self.get_config(config).await.log_err(self).ok().flatten();
if let Some(foldername) = f {
let detailed = &state.get_detailed().await;
let detailed = &state.get_detailed();
ret += "<li>";
ret += &*detailed.to_icon();
ret += " <b>";
@@ -405,7 +405,7 @@ impl Context {
}
if !folder_added && folder == &FolderMeaning::Inbox {
let detailed = &state.get_detailed().await;
let detailed = &state.get_detailed();
if let DetailedConnectivity::Error(_) = detailed {
// On the inbox thread, we also do some other things like scan_folders and run jobs
// so, maybe, the inbox is not watched, but something else went wrong
@@ -427,7 +427,7 @@ impl Context {
let outgoing_messages = stock_str::outgoing_messages(self).await;
ret += &format!("<h3>{outgoing_messages}</h3><ul><li>");
let detailed = smtp.get_detailed().await;
let detailed = smtp.get_detailed();
ret += &*detailed.to_icon();
ret += " ";
ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self).await);
@@ -551,7 +551,7 @@ impl Context {
drop(lock);
for s in &stores {
if !s.get_all_work_done().await {
if !s.get_all_work_done() {
return false;
}
}

View File

@@ -1,6 +1,6 @@
//! Implementation of [SecureJoin protocols](https://securejoin.delta.chat/).
use anyhow::{Context as _, Error, Result, ensure};
use anyhow::{Context as _, Error, Result, bail, ensure};
use deltachat_contact_tools::ContactAddress;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
@@ -32,23 +32,37 @@ use qrinvite::QrInvite;
use crate::token::Namespace;
fn inviter_progress(context: &Context, contact_id: ContactId, progress: usize) {
fn inviter_progress(
context: &Context,
contact_id: ContactId,
step: &str,
progress: usize,
) -> Result<()> {
logged_debug_assert!(
context,
progress <= 1000,
"inviter_progress: contact {contact_id}, progress={progress}, but value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success."
);
let chat_type = match step.get(..3) {
Some("vc-") => Chattype::Single,
Some("vg-") => Chattype::Group,
Some("vb-") => Chattype::OutBroadcast,
_ => bail!("Unknown securejoin step {step}"),
};
context.emit_event(EventType::SecurejoinInviterProgress {
contact_id,
chat_type,
progress,
});
Ok(())
}
/// Generates a Secure Join QR code.
///
/// With `group` set to `None` this generates a setup-contact QR code, with `group` set to a
/// [`ChatId`] generates a join-group QR code for the given chat.
pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Result<String> {
/// With `chat` set to `None` this generates a setup-contact QR code, with `chat` set to a
/// [`ChatId`] generates a join-group/join-broadcast-channel QR code for the given chat.
pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Result<String> {
/*=======================================================
==== Alice - the inviter side ====
==== Step 1 in "Setup verified contact" protocol ====
@@ -56,17 +70,19 @@ pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Resu
ensure_secret_key_exists(context).await.ok();
let chat = match group {
let chat = match chat {
Some(id) => {
let chat = Chat::load_from_db(context, id).await?;
ensure!(
chat.typ == Chattype::Group,
"Can't generate SecureJoin QR code for 1:1 chat {id}"
);
ensure!(
!chat.grpid.is_empty(),
"Can't generate SecureJoin QR code for ad-hoc group {id}"
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"Can't generate SecureJoin QR code for chat {id} of type {}",
chat.typ
);
if chat.grpid.is_empty() {
let err = format!("Can't generate QR code, chat {id} is a email thread");
error!(context, "get_securejoin_qr: {}.", err);
bail!(err);
}
Some(chat)
}
None => None,
@@ -93,24 +109,40 @@ pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Resu
utf8_percent_encode(&self_name, NON_ALPHANUMERIC_WITHOUT_DOT).to_string();
let qr = if let Some(chat) = chat {
// parameters used: a=g=x=i=s=
let group_name = chat.get_name();
let group_name_urlencoded = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string();
if sync_token {
context
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await?;
context.scheduler.interrupt_inbox().await;
}
format!(
"https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}",
fingerprint.hex(),
self_addr_urlencoded,
&group_name_urlencoded,
&chat.grpid,
&invitenumber,
&auth,
)
if chat.typ == Chattype::OutBroadcast {
let broadcast_name = chat.get_name();
let broadcast_name_urlencoded =
utf8_percent_encode(broadcast_name, NON_ALPHANUMERIC).to_string();
format!(
"https://i.delta.chat/#{}&a={}&b={}&x={}&s={}",
fingerprint.hex(),
self_addr_urlencoded,
&broadcast_name_urlencoded,
&chat.grpid,
&auth,
)
} else {
// parameters used: a=g=x=i=s=
let group_name = chat.get_name();
let group_name_urlencoded =
utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string();
format!(
"https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}",
fingerprint.hex(),
self_addr_urlencoded,
&group_name_urlencoded,
&chat.grpid,
&invitenumber,
&auth,
)
}
} else {
// parameters used: a=n=i=s=
if sync_token {
@@ -209,7 +241,7 @@ async fn verify_sender_by_fingerprint(
let contact = Contact::get_by_id(context, contact_id).await?;
let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint);
if is_verified {
mark_contact_id_as_verified(context, contact_id, ContactId::SELF).await?;
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
}
Ok(is_verified)
}
@@ -265,13 +297,34 @@ pub(crate) async fn handle_securejoin_handshake(
info!(context, "Received secure-join message {step:?}.");
let join_vg = step.starts_with("vg-");
if !matches!(step, "vg-request" | "vc-request") {
// Opportunistically protect against a theoretical 'surreptitious forwarding' attack:
// If Eve obtains a QR code from Alice and starts a securejoin with her,
// and also lets Bob scan a manipulated QR code,
// she could reencrypt the v*-request-with-auth message to Bob while maintaining the signature,
// and Bob would regard the message as valid.
//
// This attack is not actually relevant in any threat model,
// because if Eve can see Alice's QR code and have Bob scan a manipulated QR code,
// she can just do a classical MitM attack.
//
// Protecting all messages sent by Delta Chat against 'surreptitious forwarding'
// by checking the 'intended recipient fingerprint'
// will improve security (completely unrelated to the securejoin protocol)
// and is something we want to do in the future:
// https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding
if !matches!(step, "vg-request" | "vc-request" | "vb-request-with-auth") {
// We don't perform this check for `vb-request-with-auth`:
// Since the message is encrypted symmetrically,
// there are no gossip headers,
// so we can't easily do the same check as for asymmetrically encrypted secure-join messages.
// Because this check doesn't add protection in any threat model,
// we just skip it for vb-request-with-auth.
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.dc_fingerprint() == self_fingerprint && context.is_self_addr(addr).await? {
if key.public_key.dc_fingerprint() == self_fingerprint
&& context.is_self_addr(addr).await?
{
self_found = true;
break;
}
@@ -307,7 +360,7 @@ pub(crate) async fn handle_securejoin_handshake(
return Ok(HandshakeMessage::Ignore);
}
inviter_progress(context, contact_id, 300);
inviter_progress(context, contact_id, step, 300)?;
let from_addr = ContactAddress::new(&mime_message.from.addr)?;
let autocrypt_fingerprint = mime_message.autocrypt_fingerprint.as_deref().unwrap_or("");
@@ -337,7 +390,7 @@ pub(crate) async fn handle_securejoin_handshake(
========================================================*/
bob::handle_auth_required(context, mime_message).await
}
"vg-request-with-auth" | "vc-request-with-auth" => {
"vg-request-with-auth" | "vc-request-with-auth" | "vb-request-with-auth" => {
/*==========================================================
==== Alice - the inviter side ====
==== Steps 5+6 in "Setup verified contact" protocol ====
@@ -360,7 +413,8 @@ pub(crate) async fn handle_securejoin_handshake(
);
return Ok(HandshakeMessage::Ignore);
}
// verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code
// verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code,
// or that the message was encrypted with the secret written to the QR code.
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
warn!(
context,
@@ -398,11 +452,11 @@ pub(crate) async fn handle_securejoin_handshake(
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
// for setup-contact, make Alice's one-to-one chat with Bob visible
// (secure-join-information are shown in the group chat)
if !join_vg {
if step.starts_with("vc-") {
ChatId::create_for_contact(context, contact_id).await?;
}
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
inviter_progress(context, contact_id, 600);
inviter_progress(context, contact_id, step, 600)?;
if let Some(group_chat_id) = group_chat_id {
// Join group.
secure_connection_established(
@@ -412,13 +466,21 @@ pub(crate) async fn handle_securejoin_handshake(
mime_message.timestamp_sent,
)
.await?;
chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true)
.await?;
inviter_progress(context, contact_id, 800);
inviter_progress(context, contact_id, 1000);
// IMAP-delete the message to avoid handling it by another device and adding the
// member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done)
inviter_progress(context, contact_id, step, 800)?;
inviter_progress(context, contact_id, step, 1000)?;
if step == "vb-request-with-auth" {
// For broadcasts, we don't want to delete the message,
// because the other device should also internally add the member
// and see the key (because it won't see the member via autocrypt-gossip).
Ok(HandshakeMessage::Ignore)
} else {
// IMAP-delete the message to avoid handling it by another device and adding the
// member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done)
}
} else {
// Setup verified contact.
secure_connection_established(
@@ -432,7 +494,7 @@ pub(crate) async fn handle_securejoin_handshake(
.await
.context("failed sending vc-contact-confirm message")?;
inviter_progress(context, contact_id, 1000);
inviter_progress(context, contact_id, step, 1000)?;
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
}
}
@@ -447,7 +509,7 @@ pub(crate) async fn handle_securejoin_handshake(
});
Ok(HandshakeMessage::Ignore)
}
"vg-member-added" => {
"vg-member-added" | "vb-member-added" => {
let Some(member_added) = mime_message.get_header(HeaderDef::ChatGroupMemberAdded)
else {
warn!(
@@ -516,6 +578,13 @@ pub(crate) async fn observe_securejoin_on_other_device(
step,
"vg-request-with-auth" | "vc-request-with-auth" | "vg-member-added" | "vc-contact-confirm"
) {
// `vb-request-with-auth` can be ignored
// because we wouldn't be able to decrypt the message
// (it's symmetrically encrypted with the AUTH token, which only the scanning device knows);
// instead, the verification is transferred via a `MarkVerified` sync message.
// `vb-member-added` can be ignored
// because all devices receive the `vb-request-with-auth` message
// and mark Bob as verified because of this.
return Ok(HandshakeMessage::Ignore);
};
@@ -541,21 +610,21 @@ pub(crate) async fn observe_securejoin_on_other_device(
return Ok(HandshakeMessage::Ignore);
};
if key.dc_fingerprint() != contact_fingerprint {
if key.public_key.dc_fingerprint() != contact_fingerprint {
// Fingerprint does not match, ignore.
warn!(context, "Fingerprint does not match.");
return Ok(HandshakeMessage::Ignore);
}
mark_contact_id_as_verified(context, contact_id, ContactId::SELF).await?;
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
ChatId::set_protection_for_contact(context, contact_id, mime_message.timestamp_sent).await?;
if step == "vg-member-added" {
inviter_progress(context, contact_id, 800);
inviter_progress(context, contact_id, step, 800)?;
}
if step == "vg-member-added" || step == "vc-contact-confirm" {
inviter_progress(context, contact_id, 1000);
inviter_progress(context, contact_id, step, 1000)?;
}
if step == "vg-request-with-auth" || step == "vc-request-with-auth" {

View File

@@ -1,6 +1,6 @@
//! Bob's side of SecureJoin handling, the joiner-side.
use anyhow::{Context as _, Result};
use anyhow::{Context as _, Result, bail};
use super::HandshakeMessage;
use super::qrinvite::QrInvite;
@@ -10,14 +10,14 @@ use crate::contact::Origin;
use crate::context::Context;
use crate::events::EventType;
use crate::key::self_fingerprint;
use crate::log::info;
use crate::log::{LogExt as _, info};
use crate::message::{Message, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint};
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{create_smeared_timestamp, time};
use crate::tools::{smeared_time, time};
/// Starts the securejoin protocol with the QR `invite`.
///
@@ -47,16 +47,49 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
let hidden = match invite {
QrInvite::Contact { .. } => Blocked::Not,
QrInvite::Group { .. } => Blocked::Yes,
QrInvite::Broadcast { .. } => Blocked::Yes,
};
let chat_id = ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
.await
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
// The 1:1 chat with the inviter
let private_chat_id =
ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
.await
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
// The chat id of the 1:1 chat, group or broadcast that is being joined
let joining_chat_id = joining_chat_id(context, &invite, private_chat_id).await?;
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
context.emit_event(EventType::ContactsChanged(None));
// Now start the protocol and initialise the state.
{
if invite.is_v2() {
if !verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await?
{
bail!("V2 protocol failed because of fingerprint mismatch");
}
info!(context, "Using fast securejoin with symmetric encryption");
send_handshake_message(
context,
&invite,
private_chat_id,
BobHandshakeMsg::RequestWithAuth,
)
.await?;
context.emit_event(EventType::SecurejoinJoinerProgress {
contact_id: invite.contact_id(),
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
});
// Our second device won't be able to decrypt the outgoing message
// because it will be symmetrically encrypted with the AUTH token.
// So, we need to send a sync message:
let id = chat::SyncId::ContactFingerprint(invite.fingerprint().hex());
let action = chat::SyncAction::MarkVerified;
chat::sync(context, id, action).await.log_err(context).ok();
} else {
// Start the version 1 protocol and initialise the state.
let has_key = context
.sql
.exists(
@@ -71,11 +104,16 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
{
// The scanned fingerprint matches Alice's key, we can proceed to step 4b.
info!(context, "Taking securejoin protocol shortcut");
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth)
.await?;
send_handshake_message(
context,
&invite,
private_chat_id,
BobHandshakeMsg::RequestWithAuth,
)
.await?;
// Mark 1:1 chat as verified already.
chat_id
private_chat_id
.set_protection(
context,
ProtectionStatus::Protected,
@@ -89,9 +127,10 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
});
} else {
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::Request).await?;
send_handshake_message(context, &invite, private_chat_id, BobHandshakeMsg::Request)
.await?;
insert_new_db_entry(context, invite.clone(), chat_id).await?;
insert_new_db_entry(context, invite.clone(), private_chat_id).await?;
}
}
@@ -99,19 +138,35 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
QrInvite::Group { .. } => {
// For a secure-join we need to create the group and add the contact. The group will
// only become usable once the protocol is finished.
let group_chat_id = joining_chat_id(context, &invite, chat_id).await?;
if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? {
if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? {
chat::add_to_chat_contacts_table(
context,
time(),
group_chat_id,
joining_chat_id,
&[invite.contact_id()],
)
.await?;
}
let msg = stock_str::secure_join_started(context, invite.contact_id()).await;
chat::add_info_msg(context, group_chat_id, &msg, time()).await?;
Ok(group_chat_id)
chat::add_info_msg(context, joining_chat_id, &msg, time()).await?;
Ok(joining_chat_id)
}
QrInvite::Broadcast { .. } => {
if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? {
chat::add_to_chat_contacts_table(
context,
time(),
joining_chat_id,
&[invite.contact_id()],
)
.await?;
}
if !is_contact_in_chat(context, joining_chat_id, ContactId::SELF).await? {
let msg = stock_str::securejoin_wait(context).await;
chat::add_info_msg(context, joining_chat_id, &msg, time()).await?;
}
Ok(joining_chat_id)
}
QrInvite::Contact { .. } => {
// For setup-contact the BobState already ensured the 1:1 chat exists because it
@@ -120,14 +175,14 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
// race with its change, we don't add our message below the protection message.
let sort_to_bottom = true;
let (received, incoming) = (false, false);
let ts_sort = chat_id
let ts_sort = private_chat_id
.calc_sort_timestamp(context, 0, sort_to_bottom, received, incoming)
.await?;
if chat_id.is_protected(context).await? == ProtectionStatus::Unprotected {
if private_chat_id.is_protected(context).await? == ProtectionStatus::Unprotected {
let ts_start = time();
chat::add_info_msg_with_cmd(
context,
chat_id,
private_chat_id,
&stock_str::securejoin_wait(context).await,
SystemMessage::SecurejoinWait,
ts_sort,
@@ -138,7 +193,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
)
.await?;
}
Ok(chat_id)
Ok(private_chat_id)
}
}
}
@@ -204,7 +259,7 @@ pub(super) async fn handle_auth_required(
.await?;
match invite {
QrInvite::Contact { .. } => {}
QrInvite::Contact { .. } | QrInvite::Broadcast { .. } => {}
QrInvite::Group { .. } => {
// The message reads "Alice replied, waiting to be added to the group…",
// so only show it on secure-join and not on setup-contact.
@@ -253,19 +308,19 @@ pub(crate) async fn send_handshake_message(
) -> Result<()> {
let mut msg = Message {
viewtype: Viewtype::Text,
text: step.body_text(invite),
text: step.body_text(invite)?,
hidden: true,
..Default::default()
};
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
// Sends the step in Secure-Join header.
msg.param.set(Param::Arg, step.securejoin_header(invite));
msg.param.set(Param::Arg, step.securejoin_header(invite)?);
match step {
BobHandshakeMsg::Request => {
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
msg.param.set(Param::Arg2, invite.invitenumber());
msg.param.set_optional(Param::Arg2, invite.invitenumber());
msg.force_plaintext();
}
BobHandshakeMsg::RequestWithAuth => {
@@ -299,7 +354,7 @@ pub(crate) async fn send_handshake_message(
pub(crate) enum BobHandshakeMsg {
/// vc-request or vg-request
Request,
/// vc-request-with-auth or vg-request-with-auth
/// vc-request-with-auth, vg-request-with-auth, or vb-request-with-auth
RequestWithAuth,
}
@@ -309,8 +364,8 @@ impl BobHandshakeMsg {
/// This text has no significance to the protocol, but would be visible if users see
/// this email message directly, e.g. when accessing their email without using
/// DeltaChat.
fn body_text(&self, invite: &QrInvite) -> String {
format!("Secure-Join: {}", self.securejoin_header(invite))
fn body_text(&self, invite: &QrInvite) -> Result<String> {
Ok(format!("Secure-Join: {}", self.securejoin_header(invite)?))
}
/// Returns the `Secure-Join` header value.
@@ -318,17 +373,22 @@ impl BobHandshakeMsg {
/// This identifies the step this message is sending information about. Most protocol
/// steps include additional information into other headers, see
/// [`send_handshake_message`] for these.
fn securejoin_header(&self, invite: &QrInvite) -> &'static str {
match self {
fn securejoin_header(&self, invite: &QrInvite) -> Result<&'static str> {
let res = match self {
Self::Request => match invite {
QrInvite::Contact { .. } => "vc-request",
QrInvite::Group { .. } => "vg-request",
QrInvite::Broadcast { .. } => {
bail!("There is no request-with-auth for broadcasts")
}
},
Self::RequestWithAuth => match invite {
QrInvite::Contact { .. } => "vc-request-with-auth",
QrInvite::Group { .. } => "vg-request-with-auth",
QrInvite::Broadcast { .. } => "vb-request-with-auth",
},
}
};
Ok(res)
}
}
@@ -346,8 +406,19 @@ async fn joining_chat_id(
) -> Result<ChatId> {
match invite {
QrInvite::Contact { .. } => Ok(alice_chat_id),
QrInvite::Group { grpid, name, .. } => {
let group_chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? {
QrInvite::Group { grpid, name, .. }
| QrInvite::Broadcast {
broadcast_name: name,
grpid,
..
} => {
let chattype = if matches!(invite, QrInvite::Group { .. }) {
Chattype::Group
} else {
Chattype::InBroadcast
};
let chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? {
Some((chat_id, _protected, _blocked)) => {
chat_id.unblock_ex(context, Nosync).await?;
chat_id
@@ -355,18 +426,18 @@ async fn joining_chat_id(
None => {
ChatId::create_multiuser_record(
context,
Chattype::Group,
chattype,
grpid,
name,
Blocked::Not,
ProtectionStatus::Unprotected, // protection is added later as needed
None,
create_smeared_timestamp(context),
smeared_time(context),
)
.await?
}
};
Ok(group_chat_id)
Ok(chat_id)
}
}
}

View File

@@ -18,7 +18,7 @@ pub enum QrInvite {
Contact {
contact_id: ContactId,
fingerprint: Fingerprint,
invitenumber: String,
invitenumber: Option<String>,
authcode: String,
},
Group {
@@ -26,7 +26,14 @@ pub enum QrInvite {
fingerprint: Fingerprint,
name: String,
grpid: String,
invitenumber: String,
invitenumber: Option<String>,
authcode: String,
},
Broadcast {
contact_id: ContactId,
fingerprint: Fingerprint,
broadcast_name: String,
grpid: String,
authcode: String,
},
}
@@ -38,30 +45,50 @@ impl QrInvite {
/// translated to a contact ID.
pub fn contact_id(&self) -> ContactId {
match self {
Self::Contact { contact_id, .. } | Self::Group { contact_id, .. } => *contact_id,
Self::Contact { contact_id, .. }
| Self::Group { contact_id, .. }
| Self::Broadcast { contact_id, .. } => *contact_id,
}
}
/// The fingerprint of the inviter.
pub fn fingerprint(&self) -> &Fingerprint {
match self {
Self::Contact { fingerprint, .. } | Self::Group { fingerprint, .. } => fingerprint,
Self::Contact { fingerprint, .. }
| Self::Group { fingerprint, .. }
| Self::Broadcast { fingerprint, .. } => fingerprint,
}
}
/// The `INVITENUMBER` of the setup-contact/secure-join protocol.
pub fn invitenumber(&self) -> &str {
pub fn invitenumber(&self) -> Option<&str> {
match self {
Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => invitenumber,
Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => {
invitenumber.as_deref()
}
Self::Broadcast { .. } => None,
}
}
/// The `AUTH` code of the setup-contact/secure-join protocol.
pub fn authcode(&self) -> &str {
match self {
Self::Contact { authcode, .. } | Self::Group { authcode, .. } => authcode,
Self::Contact { authcode, .. }
| Self::Group { authcode, .. }
| Self::Broadcast { authcode, .. } => authcode,
}
}
/// Whether this QR code uses the faster "version 2" protocol,
/// where the first message from Bob to Alice is symmetrically encrypted
/// with the AUTH code.
/// We may decide in the future to backwards-compatibly mark QR codes as V2,
/// but for now, everything without an invite number
/// is definitely V2,
/// because the invite number is needed for V1.
pub(crate) fn is_v2(&self) -> bool {
self.invitenumber().is_none()
}
}
impl TryFrom<Qr> for QrInvite {
@@ -77,7 +104,7 @@ impl TryFrom<Qr> for QrInvite {
} => Ok(QrInvite::Contact {
contact_id,
fingerprint,
invitenumber,
invitenumber: Some(invitenumber),
authcode,
}),
Qr::AskVerifyGroup {
@@ -92,10 +119,23 @@ impl TryFrom<Qr> for QrInvite {
fingerprint,
name: grpname,
grpid,
invitenumber,
invitenumber: Some(invitenumber),
authcode,
}),
_ => bail!("Unsupported QR type"),
Qr::AskJoinBroadcast {
broadcast_name,
grpid,
contact_id,
fingerprint,
authcode,
} => Ok(QrInvite::Broadcast {
broadcast_name,
grpid,
contact_id,
fingerprint,
authcode,
}),
_ => bail!("Unsupported QR type: {qr:?}"),
}
}
}

View File

@@ -5,10 +5,12 @@ use crate::chat::{CantSendReason, remove_contact_from_chat};
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::key::self_fingerprint;
use crate::mimeparser::GossipedKey;
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, messages_e2e_encrypted};
use crate::test_utils::{
TestContext, TestContextManager, TimeShiftFalsePositiveNote, get_chat_msg,
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, get_chat_msg,
};
use crate::tools::SystemTime;
use std::time::Duration;
@@ -185,7 +187,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
);
if case == SetupContactCase::WrongAliceGossip {
let wrong_pubkey = load_self_public_key(&bob).await.unwrap();
let wrong_pubkey = GossipedKey {
public_key: load_self_public_key(&bob).await.unwrap(),
verified: false,
};
let alice_pubkey = msg
.gossiped_keys
.insert(alice_addr.to_string(), wrong_pubkey)
@@ -361,6 +366,29 @@ async fn test_setup_contact_bob_knows_alice() -> Result<()> {
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(alice).await?, true);
// Check Alice signalled success via the SecurejoinInviterProgress event.
let event = alice
.evtracker
.get_matching(|evt| {
matches!(
evt,
EventType::SecurejoinInviterProgress { progress: 1000, .. }
)
})
.await;
match event {
EventType::SecurejoinInviterProgress {
contact_id,
chat_type,
progress,
} => {
assert_eq!(contact_id, contact_bob.id);
assert_eq!(chat_type, Chattype::Single);
assert_eq!(progress, 1000);
}
_ => unreachable!(),
}
let sent = alice.pop_sent_msg().await;
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
@@ -511,6 +539,29 @@ async fn test_secure_join() -> Result<()> {
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice).await?, true);
// Check Alice signalled success via the SecurejoinInviterProgress event.
let event = alice
.evtracker
.get_matching(|evt| {
matches!(
evt,
EventType::SecurejoinInviterProgress { progress: 1000, .. }
)
})
.await;
match event {
EventType::SecurejoinInviterProgress {
contact_id,
chat_type,
progress,
} => {
assert_eq!(contact_id, contact_bob.id);
assert_eq!(chat_type, Chattype::Group);
assert_eq!(progress, 1000);
}
_ => unreachable!(),
}
let sent = alice.pop_sent_msg().await;
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
@@ -634,7 +685,7 @@ async fn test_unknown_sender() -> Result<()> {
// The message from Bob is delivered late, Bob is already removed.
let msg = alice.recv_msg(&sent).await;
assert_eq!(msg.text, "Hi hi!");
assert_eq!(msg.error.unwrap(), "Unknown sender for this chat.");
assert_eq!(msg.get_override_sender_name().unwrap(), "bob@example.net");
Ok(())
}
@@ -819,3 +870,72 @@ async fn test_wrong_auth_token() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_avatar_in_securejoin() -> Result<()> {
async fn exec_securejoin_group(
tcm: &TestContextManager,
scanner: &TestContext,
scanned: &TestContext,
) {
let chat_id = chat::create_group_chat(scanned, ProtectionStatus::Protected, "group")
.await
.unwrap();
let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap();
tcm.exec_securejoin_qr(scanner, scanned, &qr).await;
}
async fn exec_securejoin_broadcast(
tcm: &TestContextManager,
scanner: &TestContext,
scanned: &TestContext,
) {
let chat_id = chat::create_broadcast(scanned, "group".to_string())
.await
.unwrap();
let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap();
tcm.exec_securejoin_qr(scanner, scanned, &qr).await;
}
for round in 0..6 {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let file = alice.dir.path().join("avatar.png");
tokio::fs::write(&file, AVATAR_64x64_BYTES).await?;
alice
.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
.await?;
match round {
0 => {
tcm.execute_securejoin(alice, bob).await;
}
1 => {
tcm.execute_securejoin(bob, alice).await;
}
2 => {
exec_securejoin_group(&tcm, alice, bob).await;
}
3 => {
exec_securejoin_group(&tcm, bob, alice).await;
}
4 => {
exec_securejoin_broadcast(&tcm, alice, bob).await;
}
5 => {
exec_securejoin_broadcast(&tcm, bob, alice).await;
}
_ => panic!(),
}
let alice_on_bob = bob.add_or_lookup_contact_no_key(alice).await;
let avatar = alice_on_bob.get_profile_image(bob).await?.unwrap();
assert_eq!(
avatar.file_name().unwrap().to_str().unwrap(),
AVATAR_64x64_DEDUPLICATED
);
}
Ok(())
}

View File

@@ -87,7 +87,7 @@ impl Smtp {
return Ok(());
}
self.connectivity.set_connecting(context).await;
self.connectivity.set_connecting(context);
let lp = ConfiguredLoginParam::load(context)
.await?
.context("Not configured")?;
@@ -187,7 +187,7 @@ pub(crate) async fn smtp_send(
info!(context, "SMTP-sending out mime message:\n{message}");
}
smtp.connectivity.set_working(context).await;
smtp.connectivity.set_working(context);
if let Err(err) = smtp
.connect_configured(context)
@@ -414,7 +414,11 @@ pub(crate) async fn send_msg_to_smtp(
.await?;
}
SendResult::Failure(ref err) => {
if err.to_string().contains("Invalid unencrypted mail") {
if err
.to_string()
.to_lowercase()
.contains("invalid unencrypted mail")
{
let res = context
.sql
.query_row_optional(

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