Compare commits

..

99 Commits

Author SHA1 Message Date
Hocuri
d94fa41b94 Simplify logic 2024-10-15 18:02:55 +02:00
iequidoo
be59fd473e fix: Downgrade message state to InNoticed when it's fully downloaded (#2970) 2024-10-14 20:20:02 -03:00
iequidoo
e37dd8470b test: Mark not downloaded message as seen (#2970)
Add a test on what happens currently when apps call `markseen_msgs()` for not downloaded
messages. Such messages are marked as seen, but MDNs aren't sent for them. This is probably correct,
but when a message is downloaded, its state should be downgraded to `InNoticed` and when the user
really sees it, the app should issue `markseen_msgs()` again and that should trigger sending an
MDN. Currently when such a message is downloaded, it remains `InSeen`.
2024-10-14 20:20:02 -03:00
link2xt
f1ca689f99 feat: IMAP COMPRESS support 2024-10-14 14:01:22 +00:00
iequidoo
796b0d7752 refactor: update_msg_state: Don't avoid downgrading OutMdnRcvd to OutDelivered
`OutMdnRcvd` is a "virtual" message state now, only old messages can have this state in the db, so
`update_msg_state()` can be simplified.
2024-10-14 10:24:01 -03:00
link2xt
2ea5c86a5a chore(release): prepare for 1.147.1 2024-10-13 18:40:33 +00:00
iequidoo
50b250cf78 docs(CONTRIBUTING.md): Add a note on deleting/changing db columns 2024-10-13 15:34:15 -03:00
iequidoo
3c03370589 fix: Readd tokens.foreign_id column (#6038)
Otherwise backups exported from the current core and imported in versions < 1.144.0 have QR codes
not working. The breaking change which removed the column is
5a6efdff44.
2024-10-13 15:34:15 -03:00
iequidoo
8f41aed917 fix: Assume file extensions are 32 chars max and don't contain whitespace (#5338)
Before file extensions were also limited to 32 chars, but extra chars in the beginning were just cut
off, e.g. "file.with_lots_of_characters_behind_point_and_double_ending.tar.gz" was considered to
have an extension "d_point_and_double_ending.tar.gz". Better to take only "tar.gz" then.

Also don't include whitespace-containing parts in extensions. File extensions generally don't
contain whitespaces.
2024-10-11 11:04:41 -03:00
link2xt
19be12a25d chore(cargo): upgrade async_zip to 0.0.17 (#6035) 2024-10-11 00:17:40 +00:00
link2xt
6a121b87eb fix: do not emit progress 1000 when configuration is cancelled
There is already code below that emits
progress 0 or 1000 depending on whether
configuration succeeded or failed.

Before this change cancelling resulted
in progress 0 emitted,
immediately followed by progress 1000.
2024-10-10 10:34:58 +00:00
link2xt
420c0ed9b0 build(deltachat-rpc-client): add classifiers for all supported Python versions 2024-10-10 07:18:06 +00:00
link2xt
e05bb03db6 build: build Python 3.13 wheels 2024-10-10 07:18:06 +00:00
link2xt
73fcb97eef ci: update to Python 3.13 2024-10-10 07:18:06 +00:00
iequidoo
8acf391ffe refactor: MsgId::update_download_state: Don't fail if the message doesn't exist anymore
If a race happens and the message disappears, there's just nothing to do and no sense to
fail. Follow-up to 22e5bf8571.
2024-10-08 12:31:41 -03:00
iequidoo
aacea2de25 fix: Reset quota on configured address change (#5908) 2024-10-07 18:04:53 -03:00
iequidoo
b713e8cd94 chore(cargo): bump futures-* from 0.3.30 to 0.3.31
futures-util 0.3.30 is yanked.
2024-10-07 15:33:09 -03:00
link2xt
b7be0b7bf6 chore(release): prepare for 1.147.0 2024-10-05 18:04:17 +00:00
link2xt
2cb8b53256 fix: emit progress 0 if get_backup() fails 2024-10-05 17:58:24 +00:00
link2xt
a592a470cf fix: make backup reception cancellable by stopping ongoing process
This is already documented in JSON-RPC API,
but in fact ongoing process was not allocated.
2024-10-05 17:58:24 +00:00
link2xt
c4d07ab99e fix: smooth progress bar for backup transfer
Before this change progress bar only started
when database is already transferred.
Database is usually the largest file
in the whole transfer, so the transfer appears
to be stuck for the sender.

With this change progress bar
starts for backup export
as soon as connection is received
and counts bytes transferred over the connection
using AsyncWrite wrapper.

Similarly for backup import,
AsyncRead wrapper counts the bytes
received and emits progress events.
2024-10-05 17:58:24 +00:00
link2xt
eddd5a0d25 fix: make it possible to cancel ongoing backup transfer 2024-10-05 17:58:24 +00:00
link2xt
0f43d5d8f4 fix: break out of accept() loop if there is an error transferring backup 2024-10-05 17:58:24 +00:00
link2xt
2e6d3aebae docs(CONTRIBUTING.md): add more SQL advices 2024-10-05 13:09:49 +00:00
link2xt
650995dc41 feat(deltachat-repl): print send-backup QR code to the terminal 2024-10-04 22:53:30 +00:00
link2xt
283a1f1653 fix: skip unconfigured folders in background_fetch()
Otherwise `background_fetch()` fails on unconfigured Mvbox,
which is typical for chatmail accounts,
and does not get to checking QUOTA ever.
2024-10-04 21:54:42 +00:00
link2xt
d33909a054 feat: reuse existing connections in background_fetch() if I/O is started 2024-10-04 21:54:42 +00:00
link2xt
129be3aa27 feat(deltachat-repl): add fetch command to test background_fetch() 2024-10-04 15:52:59 +00:00
link2xt
8a88479d8f fix(query_row_optional): do not treat rows with NULL as missing rows
Instead of treating NULL type error
as absence of the row,
handle NULL values with SQL.
Previously we sometimes
accidentally treated a single column
being NULL as the lack of the whole row.
2024-10-04 14:43:06 +00:00
Hocuri
5711f2fe3a feat: More context for the "Cannot establish guaranteed..." info message (#6022)
The "Cannot establish guaranteed end-to-end encryption with ..." info
message can have lots of causes, and it happened twice to us now that it
took us some time to figure out which one it is.

So, include some more detail in the info message by simply adding the
non-translated error message in parantheses.

If we want to put in some more effort for nicer error messages, we
could:
- Introduce one new translated string "Cannot establish guaranteed
end-to-end encryption with …. Cause: %2$s" or similar (and remove the
old stock string)
- And/Or: Introduce new translated strings for all the possible errors
- And/Or: Maybe reword it in order to account better for the case that
the chat already is marked as g-e2ee, or use a different wording
(because if the chat is marked as g-e2ee then it might be nice to notify
the user that something may have gone wrong, but it's still working,
just that maybe the other side doesn't have us verified now)


![Screenshot_20241003-222245](https://github.com/user-attachments/assets/c064c82e-01ac-4bac-ab11-3c9ac9db5298)
2024-10-04 13:51:06 +02:00
link2xt
46922d4d9d fix: do not attempt to reference info messages
Info messages are added
at the beginning of unpromoted group chats
("Others will only see this group after you sent a first message."),
may be created by WebXDC etc.

They are not sent outside
and have local Message-ID that
is not known to other recipients
so they should be skipped when constructing
In-Reply-To and References.
2024-10-03 21:49:58 +00:00
link2xt
75fe4e106a api!: remove deprecated get_next_media() APIs 2024-10-03 21:04:03 +00:00
iequidoo
7c60ac863e feat: MsgId::get_info(): Report original filename as well 2024-10-03 15:49:03 -03:00
link2xt
fa9bd7f144 chore(release): prepare for 1.146.0 2024-10-03 17:21:42 +00:00
link2xt
22e5bf8571 fix(download_msg): do not fail if the message does not exist anymore
Without this fix IMAP loop may get stuck
trying to download non-existing message over and over
like this:
```
src/imap.rs:372: Logging into IMAP server with LOGIN.
src/imap.rs:388: Successfully logged into IMAP server
src/scheduler.rs:361: Failed to download message Msg#3467: Message Msg#3467 does not exist.
src/scheduler.rs:418: Failed fetch_idle: Failed to download messages: Message Msg#3467 does not exist
```

The whole download operation fails
due to attempt to set the state of non-existing message
to "failed". Now download of the message
will "succeed" if the message does not exist
and we don't try to set its state.
2024-10-03 17:13:53 +00:00
link2xt
c8ba516e83 refactor(decode_ideltachat): construct error message lazily 2024-10-03 15:39:27 +00:00
dependabot[bot]
4b021f509c chore(cargo): bump syn from 2.0.77 to 2.0.79
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.77 to 2.0.79.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.77...2.0.79)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-02 17:06:38 -03:00
dependabot[bot]
bd1e06cfa7 Merge pull request #6003 from deltachat/dependabot/cargo/serde-1.0.210 2024-10-02 20:05:43 +00:00
dependabot[bot]
11e5a00366 chore(cargo): bump quick-xml from 0.36.1 to 0.36.2
Bumps [quick-xml](https://github.com/tafia/quick-xml) from 0.36.1 to 0.36.2.
- [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.36.1...v0.36.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-02 17:04:25 -03:00
dependabot[bot]
5fdecdcc16 chore(cargo): bump serde from 1.0.209 to 1.0.210
Bumps [serde](https://github.com/serde-rs/serde) from 1.0.209 to 1.0.210.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.209...v1.0.210)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-02 02:32:20 +00:00
dependabot[bot]
77b899813c Merge pull request #6000 from deltachat/dependabot/cargo/rustls-pki-types-1.9.0 2024-10-02 02:30:36 +00:00
dependabot[bot]
7843e0ed29 Merge pull request #6001 from deltachat/dependabot/cargo/hyper-util-0.1.9 2024-10-02 02:29:26 +00:00
dependabot[bot]
a036c86857 Merge pull request #6002 from deltachat/dependabot/cargo/pretty_assertions-1.4.1 2024-10-02 02:28:57 +00:00
dependabot[bot]
e535a6f859 Merge pull request #6012 from deltachat/dependabot/cargo/tempfile-3.13.0 2024-10-02 02:28:00 +00:00
dependabot[bot]
5384d5f75d Merge pull request #6008 from deltachat/dependabot/cargo/libc-0.2.159 2024-10-02 02:27:38 +00:00
dependabot[bot]
c569696fff Merge pull request #6007 from deltachat/dependabot/cargo/bytes-1.7.2 2024-10-02 02:27:00 +00:00
dependabot[bot]
a6732f5a5c Merge pull request #6011 from deltachat/dependabot/cargo/thiserror-1.0.64 2024-10-02 02:26:25 +00:00
dependabot[bot]
9978f89b1b Merge pull request #6005 from deltachat/dependabot/cargo/tokio-stream-0.1.16 2024-10-02 02:26:02 +00:00
dependabot[bot]
dbca15e5ef Merge pull request #6010 from deltachat/dependabot/cargo/anyhow-1.0.89 2024-10-02 02:25:19 +00:00
dependabot[bot]
91649effa6 chore(cargo): bump tempfile from 3.10.1 to 3.13.0
Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.10.1 to 3.13.0.
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/compare/v3.10.1...v3.13.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:46:25 +00:00
dependabot[bot]
672ff58e3c chore(cargo): bump thiserror from 1.0.63 to 1.0.64
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 1.0.63 to 1.0.64.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/1.0.63...1.0.64)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:46:12 +00:00
dependabot[bot]
a85b7ceb9c chore(cargo): bump anyhow from 1.0.86 to 1.0.89
Bumps [anyhow](https://github.com/dtolnay/anyhow) from 1.0.86 to 1.0.89.
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.86...1.0.89)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:45:56 +00:00
dependabot[bot]
943ec19de4 chore(cargo): bump libc from 0.2.158 to 0.2.159
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.158 to 0.2.159.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.159/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.158...0.2.159)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:45:11 +00:00
dependabot[bot]
733da91c5c chore(cargo): bump bytes from 1.7.1 to 1.7.2
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.7.1 to 1.7.2.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.7.1...v1.7.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:44:50 +00:00
dependabot[bot]
d899cc730a chore(cargo): bump tokio-stream from 0.1.15 to 0.1.16
Bumps [tokio-stream](https://github.com/tokio-rs/tokio) from 0.1.15 to 0.1.16.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-stream-0.1.15...tokio-stream-0.1.16)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:43:59 +00:00
dependabot[bot]
5872b64265 chore(cargo): bump pretty_assertions from 1.4.0 to 1.4.1
Bumps [pretty_assertions](https://github.com/rust-pretty-assertions/rust-pretty-assertions) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/rust-pretty-assertions/rust-pretty-assertions/releases)
- [Changelog](https://github.com/rust-pretty-assertions/rust-pretty-assertions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-pretty-assertions/rust-pretty-assertions/compare/v1.4.0...v1.4.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:42:57 +00:00
dependabot[bot]
5d8035f741 chore(cargo): bump hyper-util from 0.1.7 to 0.1.9
Bumps [hyper-util](https://github.com/hyperium/hyper-util) from 0.1.7 to 0.1.9.
- [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.7...v0.1.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:42:32 +00:00
dependabot[bot]
3d183336f5 chore(cargo): bump rustls-pki-types from 1.8.0 to 1.9.0
Bumps [rustls-pki-types](https://github.com/rustls/pki-types) from 1.8.0 to 1.9.0.
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.8.0...v/1.9.0)

---
updated-dependencies:
- dependency-name: rustls-pki-types
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-01 21:42:18 +00:00
WofWca
9c931c22cc refactor: better log message for failed QR scan
It did not interpolate the `{prefix}`,
it just printed it in plain text.
2024-09-30 17:34:54 -03:00
link2xt
78a0d7501b feat: use Rustls instead of native TLS for HTTPS requests
HTTPS requests are used to fetch
remote images in HTML emails,
to fetch autoconfig XML,
to POST requests for `DCACCOUNT:` QR codes
to make OAuth 2 API requests
and to connect to HTTPS proxies.

Rustls is more aggressive than OpenSSL
in deprecating cryptographic algorithms
so we cannot use it for IMAP and SMTP
to avoid breaking compatibility,
but for HTTPS requests listed
above this should not result in problems.

As HTTPS requests use only strict TLS checks,
there is no `strict_tls` argument
in `wrap_rustls` function.

Rustls is already used by iroh,
so this change does not introduce new dependencies.
2024-09-26 22:35:44 +00:00
link2xt
638da904e7 refactor: merge build_tls() function into wrap_tls() 2024-09-26 22:35:44 +00:00
iequidoo
fe0c9958a6 feat: Assign message to ad-hoc group with matching name and members (#5385)
This should fix ad-hoc groups splitting when messages are fetched out of order from different
folders or otherwise reordered, or some messages are missing so that the messages reference chain is
broken, or a member was removed from the thread and readded later, etc. Even if this way two
different threads are merged, it looks acceptable, having many threads with the same name/subject
and members isn't a common use case.
2024-09-26 17:09:11 -03:00
iequidoo
c469fcb435 refactor: Move group name calculation out of create_adhoc_group() 2024-09-26 17:09:11 -03:00
link2xt
02db6bcb8e chore(release): prepare for 1.145.0 2024-09-26 19:22:10 +00:00
link2xt
4b74c9d85f fix: avoid changing delete_server_after default for existing configurations 2024-09-26 19:18:12 +00:00
link2xt
040ac0ffe3 refactor: do not wrap shadowsocks::ProxyClientStream
Updated `shadowsocks` implements `Debug` for the type,
so there is no need to wrap it.
2024-09-26 14:28:08 +00:00
link2xt
bfef129dbf chore: sort dependency list 2024-09-22 18:00:55 +00:00
link2xt
486ea3a358 chore(release): prepare for 1.144.0 2024-09-21 18:53:02 +00:00
link2xt
624ae86913 api!: make QR code type for proxy not specific to SOCKS5 (#5980) 2024-09-21 18:26:07 +00:00
link2xt
b47b96d5d6 chore(cargo): update iroh to 0.25
According to
<https://www.iroh.computer/blog/iroh-0-25-0-custom-protocols-for-all>
gossip now handles updating direct addresses automatically.
2024-09-20 22:56:24 +00:00
link2xt
f6b5c5d150 feat: generate 144-bit group IDs
Instead of generating 72 random bits
and reducing them to 66 bits of Base64 characters,
generate 144 bits (18 bytes)
which is exactly 24 Base64 characters.

This should still be accepted by existing
Delta Chat clients which expect group ID
to be between 11 and 32 characters.

Message-ID creation is also simplified
to not have `Mr.` prefix
and dot in between two IDs.
Now it is a single ID followed by `@localhost`.

Some outdated documentation comments
are removed, e.g. group messages
don't start with `Gr.` already.
2024-09-20 22:38:28 +00:00
link2xt
9cc65c615c feat(smtp): more verbose SMTP connection establishment errors
The greeting is now always read manually,
even for STARTTLS connections,
so the errors returned on failure to read form the stream
are the same regardless of the connection type.
2024-09-20 20:37:47 +00:00
iequidoo
d6845bd5e9 feat: Use IMAP APPEND command to upload sync messages (#5845)
Why:
- With IMAP APPEND we can upload messages directly to the DeltaChat folder (for non-chatmail
  accounts).
- We can set the `\Seen` flag immediately so that if the user has other MUA, it doesn't alert about
  a new message if it's just a sync message (there were several such reports on the support
  forum). Though this also isn't useful for chatmail.
- We don't need SMTP envelope and overall remove some overhead on processing sync messages.
2024-09-20 17:07:45 -03:00
iequidoo
0b908db272 chore(deps): bump async-imap from 0.10.0 to 0.10.1 2024-09-20 17:07:45 -03:00
iequidoo
841ed43f11 feat: Don't put displayname into From/To/Sender if it equals to address (#5983)
If a displayname equals to the address, adding it looks excessive.
Moreover, it's not useful for Delta Chat receiving the message because
`sanitize_name_and_addr()` removes such a displayname anyway. Also now
at least DC Android requires specifying profile name, so there should be
a fallback for users having meaningful addresses to keep the old
behaviour when Core generates `From` w/o the profile name, and this
question has already appeared on the forum.
2024-09-20 15:59:33 -03:00
link2xt
60cd6f56be chore(cargo): update lazy_static to 1.5.0
This removes duplicate `spin` dependency.
2024-09-18 15:31:13 +00:00
link2xt
060fd55249 feat: HTTP(S) tunneling
HTTP proxy is tested with deltachat-repl
against local Privoxy
using
```
> set proxy_url http://127.0.0.1:8118/
> setqr dcaccount:https://nine.testrun.org/new
> configure
> connect
```
2024-09-18 10:52:31 +00:00
link2xt
38c7f7300e Partially revert "test(test-data): remove public keys that can be derived from secret keys" (#5977)
This reverts commit 1caf672904.

Otherwise public key signature is regenerated each time the key is
loaded and test `key::tests::test_load_self_existing` which loads the
key twice fails when two loads happen on different seconds.

Closes #5976
2024-09-18 09:48:01 +00:00
link2xt
f7a705c6da refactor: use KeyPair::new() in create_keypair() 2024-09-16 20:51:16 +00:00
iequidoo
f497e4dd12 docs: Why search_msgs() only looks at the first kilobytes of long messages 2024-09-16 17:14:51 -03:00
iequidoo
0a63083df7 fix: Shorten message text in locally sent messages too (#2281) 2024-09-16 17:14:51 -03:00
iequidoo
5a6efdff44 fix: Save QR code token regardless of whether the group exists (#5954)
Groups promotion to other devices and QR code tokens synchronisation are not synchronised processes,
so there are reasons why a QR code token may arrive earlier than the first group message:
- We are going to upload sync messages via IMAP while group messages are sent by SMTP.
- If sync messages go to the mvbox, they can be fetched earlier than group messages from Inbox.
2024-09-16 16:40:26 -03:00
link2xt
7efb5a269c docs(CONTRIBUTING.md): document how to format SQL statements 2024-09-16 18:11:42 +00:00
link2xt
1caf672904 test(test-data): remove public keys that can be derived from secret keys 2024-09-16 17:00:16 +00:00
link2xt
7743072411 refactor: remove addr from KeyPair 2024-09-16 17:00:16 +00:00
link2xt
c461c4f02e refactor: do not store deprecated addr and is_default into keypairs 2024-09-16 17:00:16 +00:00
iequidoo
5b597f3a95 feat: Don't SMTP-send messages to self-chat if BccSelf is disabled
`chat::create_send_msg_jobs()` already handles `Config::BccSelf` as needed. The only exception is
Autocrypt setup messages. This change unifies the logic for the self-chat and groups only containing
`SELF`.
2024-09-15 23:48:06 -03:00
iequidoo
b69488685f feat: Make resending OutPending messages possible (#5817)
This makes possible to schedule one more sending of the message, the existing jobs are not
cancelled. Otherwise it's complicated to implement bots that resend messages when a new member joins
the group.
2024-09-15 16:27:39 -03:00
link2xt
afb01e3e90 chore: update provider database
This change removes OAuth2 for Gmail
as Delta Chat does not have a working
client ID anymore.
Tests are adjusted to test against Yandex
and MX queries for OAuth2 are always disabled
because they were only used to detect Google Workspace.
2024-09-13 17:58:25 +00:00
link2xt
7ff14dc26b feat: log unexpected message state when resending fails 2024-09-12 05:06:05 +00:00
link2xt
0c33064193 chore(release): prepare for 1.143.0 2024-09-12 01:52:14 +00:00
link2xt
61d77584e8 chore(cargo): update typescript-type-def to 0.5.12
This removes unmaintained proc-macro-error dependency.
2024-09-12 01:35:43 +00:00
link2xt
37ca9d7319 feat: shadowsocks support
This change introduces new config options
`proxy_enabled` and `proxy_url`
that replace `socks5_*`.

Tested with deltachat-repl
by starting it with
`cargo run --locked -p deltachat-repl -- deltachat-db` and running
```
> set proxy_enabled 1
> set proxy_url ss://...
> setqr dcaccount:https://chatmail.example.org/new
> configure
```
2024-09-12 00:22:09 +00:00
iequidoo
2c136f6355 refactor: get_config_bool_opt(): Return None if only default value exists
And also:
- Make it `pub(crate)`.
- Use it in `should_request_mdns()` as using `config_exists()` there isn't correct because the
  latter doesn't look at environment.
2024-09-10 18:10:59 -03:00
iequidoo
52dcc7e350 refactor: Make Context::config_exists() crate-public 2024-09-10 18:10:59 -03:00
iequidoo
ff6488371c feat: Delete messages from a chatmail server immediately by default (#5805) (#5840)
I.e. treat `DeleteServerAfter == None` as "delete at once". But when a backup is exported, set
`DeleteServerAfter` to 0 so that the server decides when to delete messages, in order not to break
the multi-device case. Even if a backup is not aimed for deploying more devices, `DeleteServerAfter`
must be set to 0, otherwise the backup is half-useful because after a restoration the user wouldn't
see new messages deleted by the device after the backup was done. But if the user explicitly set
`DeleteServerAfter`, don't change it when exporting a backup. Anyway even for non-chatmail case the
app should warn the user before a backup export if they have `DeleteServerAfter` enabled.

Also do the same after a backup import. While this isn't reliable as we can crash in between, this
is a problem only for old backups, new backups already have `DeleteServerAfter` set if necessary.

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2024-09-08 16:53:56 -03:00
link2xt
0782b5abdd ci: update Rust to 1.81.0 2024-09-08 07:08:12 +00:00
link2xt
2e2ba96d75 chore(cargo-deny): silence unmaintained proc-macro-error warning 2024-09-08 06:35:01 +00:00
iequidoo
853e38e054 feat: ChatId::create_for_contact_with_blocked: Don't emit events on no op 2024-09-05 10:46:30 -03:00
87 changed files with 3466 additions and 1771 deletions

View File

@@ -24,7 +24,7 @@ jobs:
name: Lint Rust
runs-on: ubuntu-latest
env:
RUSTUP_TOOLCHAIN: 1.80.1
RUSTUP_TOOLCHAIN: 1.81.0
steps:
- uses: actions/checkout@v4
with:
@@ -95,11 +95,11 @@ jobs:
matrix:
include:
- os: ubuntu-latest
rust: 1.80.1
rust: 1.81.0
- os: windows-latest
rust: 1.80.1
rust: 1.81.0
- os: macos-latest
rust: 1.80.1
rust: 1.81.0
# Minimum Supported Rust Version = 1.77.0
- os: ubuntu-latest
@@ -211,9 +211,9 @@ jobs:
include:
# Currently used Rust version.
- os: ubuntu-latest
python: 3.12
python: 3.13
- os: macos-latest
python: 3.12
python: 3.13
# PyPy tests
- os: ubuntu-latest
@@ -263,11 +263,11 @@ jobs:
matrix:
include:
- os: ubuntu-latest
python: 3.12
python: 3.13
- os: macos-latest
python: 3.12
python: 3.13
- os: windows-latest
python: 3.12
python: 3.13
# PyPy tests
- os: ubuntu-latest

View File

@@ -1,5 +1,201 @@
# Changelog
## [1.147.1] - 2024-10-13
### Build system
- Build Python 3.13 wheels.
- deltachat-rpc-client: Add classifiers for all supported Python versions.
### CI
- Update to Python 3.13.
### Documentation
- CONTRIBUTING.md: Add a note on deleting/changing db columns.
### Fixes
- Reset quota on configured address change ([#5908](https://github.com/deltachat/deltachat-core-rust/pull/5908)).
- Do not emit progress 1000 when configuration is cancelled.
- Assume file extensions are 32 chars max and don't contain whitespace ([#5338](https://github.com/deltachat/deltachat-core-rust/pull/5338)).
- Readd tokens.foreign_id column ([#6038](https://github.com/deltachat/deltachat-core-rust/pull/6038)).
### Miscellaneous Tasks
- cargo: Bump futures-* from 0.3.30 to 0.3.31.
- cargo: Upgrade async_zip to 0.0.17 ([#6035](https://github.com/deltachat/deltachat-core-rust/pull/6035)).
### Refactor
- MsgId::update_download_state: Don't fail if the message doesn't exist anymore.
## [1.147.0] - 2024-10-05
### API-Changes
- [**breaking**] Remove deprecated get_next_media() APIs.
### Features / Changes
- Reuse existing connections in background_fetch() if I/O is started.
- MsgId::get_info(): Report original filename as well.
- More context for the "Cannot establish guaranteed..." info message ([#6022](https://github.com/deltachat/deltachat-core-rust/pull/6022)).
- deltachat-repl: Add `fetch` command to test `background_fetch()`.
- deltachat-repl: Print send-backup QR code to the terminal.
### Fixes
- Do not attempt to reference info messages.
- query_row_optional: Do not treat rows with NULL as missing rows.
- Skip unconfigured folders in `background_fetch()`.
- Break out of accept() loop if there is an error transferring backup.
- Make it possible to cancel ongoing backup transfer.
- Make backup reception cancellable by stopping ongoing process.
- Smooth progress bar for backup transfer.
- Emit progress 0 if get_backup() fails.
### Documentation
- CONTRIBUTING.md: Add more SQL advices.
## [1.146.0] - 2024-10-03
### Fixes
- download_msg: Do not fail if the message does not exist anymore.
- Better log message for failed QR scan.
### Features / Changes
- Assign message to ad-hoc group with matching name and members ([#5385](https://github.com/deltachat/deltachat-core-rust/pull/5385)).
- Use Rustls instead of native TLS for HTTPS requests.
### Miscellaneous Tasks
- cargo: Bump anyhow from 1.0.86 to 1.0.89.
- cargo: Bump tokio-stream from 0.1.15 to 0.1.16.
- cargo: Bump thiserror from 1.0.63 to 1.0.64.
- cargo: Bump bytes from 1.7.1 to 1.7.2.
- cargo: Bump libc from 0.2.158 to 0.2.159.
- cargo: Bump tempfile from 3.10.1 to 3.13.0.
- cargo: Bump pretty_assertions from 1.4.0 to 1.4.1.
- cargo: Bump hyper-util from 0.1.7 to 0.1.9.
- cargo: Bump rustls-pki-types from 1.8.0 to 1.9.0.
- cargo: Bump quick-xml from 0.36.1 to 0.36.2.
- cargo: Bump serde from 1.0.209 to 1.0.210.
- cargo: Bump syn from 2.0.77 to 2.0.79.
### Refactor
- Move group name calculation out of create_adhoc_group().
- Merge build_tls() function into wrap_tls().
## [1.145.0] - 2024-09-26
### Fixes
- Avoid changing `delete_server_after` default for existing configurations.
### Miscellaneous Tasks
- Sort dependency list.
### Refactor
- Do not wrap shadowsocks::ProxyClientStream.
## [1.144.0] - 2024-09-21
### API-Changes
- [**breaking**] Make QR code type for proxy not specific to SOCKS5 ([#5980](https://github.com/deltachat/deltachat-core-rust/pull/5980)).
`DC_QR_SOCKS5_PROXY` is replaced with `DC_QR_PROXY`.
### Features / Changes
- Make resending OutPending messages possible ([#5817](https://github.com/deltachat/deltachat-core-rust/pull/5817)).
- Don't SMTP-send messages to self-chat if BccSelf is disabled.
- HTTP(S) tunneling.
- Don't put displayname into From/To/Sender if it equals to address ([#5983](https://github.com/deltachat/deltachat-core-rust/pull/5983)).
- Use IMAP APPEND command to upload sync messages ([#5845](https://github.com/deltachat/deltachat-core-rust/pull/5845)).
- Generate 144-bit group IDs.
- smtp: More verbose SMTP connection establishment errors.
- Log unexpected message state when resending fails.
### Fixes
- Save QR code token regardless of whether the group exists ([#5954](https://github.com/deltachat/deltachat-core-rust/pull/5954)).
- Shorten message text in locally sent messages too ([#2281](https://github.com/deltachat/deltachat-core-rust/pull/2281)).
### Documentation
- CONTRIBUTING.md: Document how to format SQL statements.
### Miscellaneous Tasks
- Update provider database.
- cargo: Update iroh to 0.25.
- cargo: Update lazy_static to 1.5.0.
- deps: Bump async-imap from 0.10.0 to 0.10.1.
### Refactor
- Do not store deprecated `addr` and `is_default` into `keypairs`.
- Remove `addr` from KeyPair.
- Use `KeyPair::new()` in `create_keypair()`.
## [1.143.0] - 2024-09-12
### Features / Changes
- Automatic reconfiguration, e.g. switching to implicit TLS if STARTTLS port stops working.
- Always use preloaded DNS results.
- Add "Auto-Submitted: auto-replied" header to appropriate SecureJoin messages.
- Parallelize IMAP and SMTP connection attempts ([#5915](https://github.com/deltachat/deltachat-core-rust/pull/5915)).
- securejoin: Ignore invalid *-request-with-auth messages silently.
- ChatId::create_for_contact_with_blocked: Don't emit events on no op.
- Delete messages from a chatmail server immediately by default ([#5805](https://github.com/deltachat/deltachat-core-rust/pull/5805)) ([#5840](https://github.com/deltachat/deltachat-core-rust/pull/5840)).
- Shadowsocks support.
- Recognize t.me SOCKS5 proxy QR codes ([#5895](https://github.com/deltachat/deltachat-core-rust/pull/5895))
- Remove old iroh 0.4 and support for old `DCBACKUP` QR codes.
### Fixes
- http: Set I/O timeout to 1 minute rather than whole request timeout.
- Add Auto-Submitted header in a single place.
- Do not allow quotes with "... wrote:" headers in chat messages.
- Don't sync QR code token before populating the group ([#5935](https://github.com/deltachat/deltachat-core-rust/pull/5935)).
### Documentation
- Document that `bcc_self` is enabled by default.
### CI
- Update Rust to 1.81.0.
### Miscellaneous Tasks
- Update provider database.
- cargo: Update iroh to 0.23.0.
- cargo: Reduce number of duplicate dependencies.
- cargo: Replace unmaintained ansi_term with nu-ansi-term.
- Replace `reqwest` with direct usage of `hyper`.
### Refactor
- login_param: Use Config:: constants to avoid typos in key names.
- Make Context::config_exists() crate-public.
- Get_config_bool_opt(): Return None if only default value exists.
### Tests
- Test that alternative port 443 works.
- Alice is (non-)bot on Bob's side after QR contact setup.
## [1.142.12] - 2024-09-02
### Fixes
@@ -4806,3 +5002,9 @@ https://github.com/deltachat/deltachat-core-rust/pulls?q=is%3Apr+is%3Aclosed
[1.142.10]: https://github.com/deltachat/deltachat-core-rust/compare/v1.142.9..v1.142.10
[1.142.11]: https://github.com/deltachat/deltachat-core-rust/compare/v1.142.10..v1.142.11
[1.142.12]: https://github.com/deltachat/deltachat-core-rust/compare/v1.142.11..v1.142.12
[1.143.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.142.12..v1.143.0
[1.144.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.143.0..v1.144.0
[1.145.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.144.0..v1.145.0
[1.146.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.145.0..v1.146.0
[1.147.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.146.0..v1.147.0
[1.147.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.147.0..v1.147.1

View File

@@ -32,6 +32,66 @@ on the contributing page: <https://github.com/deltachat/deltachat-core-rust/cont
We format the code using `rustfmt`. Run `cargo fmt` prior to committing the code.
Run `scripts/clippy.sh` to check the code for common mistakes with [Clippy].
### SQL
Multi-line SQL statements should be formatted using string literals,
for example
```
sql.execute(
"CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT DEFAULT '' NOT NULL -- message text
) STRICT",
)
.await?;
```
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
or [`indoc!](https://docs.rs/indoc).
Do not escape newlines like this:
```
sql.execute(
"CREATE TABLE messages ( \
id INTEGER PRIMARY KEY AUTOINCREMENT, \
text TEXT DEFAULT '' NOT NULL \
) STRICT",
)
.await?;
```
Escaping newlines
is prone to errors like this if space before backslash is missing:
```
"SELECT foo\
FROM bar"
```
Literal above results in `SELECT fooFROM bar` string.
This style also does not allow using `--` comments.
---
Declare new SQL tables with [`STRICT`](https://sqlite.org/stricttables.html) keyword
to make SQLite check column types.
Declare primary keys with [`AUTOINCREMENT`](https://www.sqlite.org/autoinc.html) keyword.
This avoids reuse of the row IDs and can avoid dangerous bugs
like forwarding wrong message because the message was deleted
and another message took its row ID.
Declare all new columns as `NOT NULL`
and set the `DEFAULT` value if it is optional so the column can be skipped in `INSERT` statements.
Dealing with `NULL` values both in SQL and in Rust is tricky and we try to avoid it.
If column is already declared without `NOT NULL`, use `IFNULL` function to provide default value when selecting it.
Use `HAVING COUNT(*) > 0` clause
to [prevent aggregate functions such as `MIN` and `MAX` from returning `NULL`](https://stackoverflow.com/questions/66527856/aggregate-functions-max-etc-return-null-instead-of-no-rows).
Don't delete unused columns too early, but maybe after several months/releases, unused columns are
still used by older versions, so deleting them breaks downgrading the core or importing a backup in
an older version. Also don't change the column type, consider adding a new column with another name
instead. Finally, never change column semantics, this is especially dangerous because the `STRICT`
keyword doesn't help here.
### Commit messages
Commit messages follow the [Conventional Commits] notation.
We use [git-cliff] to generate the changelog from commit messages before the release.

855
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "1.142.12"
version = "1.147.1"
edition = "2021"
license = "MPL-2.0"
rust-version = "1.77"
@@ -41,10 +41,10 @@ ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true }
async-broadcast = "0.7.1"
async-channel = { workspace = true }
async-imap = { version = "0.10.0", default-features = false, features = ["runtime-tokio"] }
async-imap = { version = "0.10.2", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.9", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.12", default-features = false, features = ["deflate", "fs"] }
async_zip = { version = "0.0.17", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
brotli = { version = "6", default-features=false, features = ["std"] }
bytes = "1"
@@ -54,17 +54,17 @@ encoded-words = { git = "https://github.com/async-email/encoded-words", branch =
escaper = "0.1"
fast-socks5 = "0.9"
fd-lock = "4"
futures = { workspace = true }
futures-lite = { workspace = true }
futures = { workspace = true }
hex = "0.4.0"
hickory-resolver = "0.24"
hickory-resolver = "=0.25.0-alpha.2"
http-body-util = "0.1.2"
humansize = "2"
hyper = "1"
hyper-util = "0.1.7"
hyper-util = "0.1.9"
image = { version = "0.25.1", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh-net = { version = "0.23.0", default-features = false }
iroh-gossip = { version = "0.23.0", default-features = false, features = ["net"] }
iroh-gossip = { version = "0.25.0", default-features = false, features = ["net"] }
iroh-net = { version = "0.25.0", default-features = false }
kamadak-exif = "0.5.3"
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
libc = { workspace = true }
@@ -74,9 +74,10 @@ num_cpus = "1.16"
num-derive = "0.4"
num-traits = { workspace = true }
once_cell = { workspace = true }
percent-encoding = "2.3"
parking_lot = "0.12"
percent-encoding = "2.3"
pgp = { version = "0.13.2", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = "0.36"
quoted_printable = "0.5"
@@ -84,25 +85,30 @@ rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
rustls-pki-types = "1.9.0"
rustls = { version = "0.23.13", default-features = false }
sanitize-filename = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
sha-1 = "0.10"
shadowsocks = { version = "1.21.0", default-features = false, features = ["aead-cipher-2022"] }
smallvec = "1.13.2"
strum = "0.26"
strum_macros = "0.26"
tagger = "4.3.4"
textwrap = "0.16.1"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
tokio-io-timeout = "1.2.0"
tokio-stream = { version = "0.1.15", features = ["fs"] }
tokio-rustls = { version = "0.26.0", default-features = false }
tokio-stream = { version = "0.1.16", 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"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
webpki-roots = "0.26.6"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
@@ -110,11 +116,11 @@ criterion = { version = "0.5.1", features = ["async_tokio"] }
futures-lite = { workspace = true }
log = { workspace = true }
nu-ansi-term = { workspace = true }
pretty_assertions = "1.4.1"
proptest = { version = "1", default-features = false, features = ["std"] }
tempfile = { workspace = true }
testdir = "0.9.0"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
pretty_assertions = "1.3.0"
[workspace]
members = [
@@ -176,9 +182,9 @@ rand = "0.8"
regex = "1.10"
rusqlite = "0.32"
sanitize-filename = "0.5"
serde_json = "1"
serde = "1.0"
tempfile = "3.10.1"
serde_json = "1"
tempfile = "3.13.0"
thiserror = "1"
# 1.38 is the latest version before `mio` dependency update
@@ -196,7 +202,6 @@ yerpc = "0.6.2"
default = ["vendored"]
internals = []
vendored = [
"async-native-tls/vendored",
"rusqlite/bundled-sqlcipher-vendored-openssl"
]

View File

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

View File

@@ -403,11 +403,8 @@ char* dc_get_blobdir (const dc_context_t* context);
* - `send_port` = SMTP-port, guessed if left out
* - `send_security`= SMTP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `server_flags` = IMAP-/SMTP-flags as a combination of @ref DC_LP flags, guessed if left out
* - `socks5_enabled` = SOCKS5 enabled
* - `socks5_host` = SOCKS5 proxy server host
* - `socks5_port` = SOCKS5 proxy server port
* - `socks5_user` = SOCKS5 proxy username
* - `socks5_password` = SOCKS5 proxy password
* - `proxy_enabled` = Proxy enabled. Disabled by default.
* - `proxy_url` = Proxy URL. May contain multiple URLs separated by newline, but only the first one is used.
* - `imap_certificate_checks` = how to check IMAP certificates, one of the @ref DC_CERTCK flags, defaults to #DC_CERTCK_AUTO (0)
* - `smtp_certificate_checks` = deprecated option, should be set to the same value as `imap_certificate_checks` but ignored by the new core
* - `displayname` = Own name to use when sending messages. MUAs are allowed to spread this way e.g. using CC, defaults to empty
@@ -867,13 +864,10 @@ void dc_maybe_network (dc_context_t* context);
*
* @memberof dc_context_t
* @param context The context as created by dc_context_new().
* @param addr The e-mail address of the user. This must match the
* configured_addr setting of the context as well as the UID of the key.
* @param public_data Ignored, actual public key is extracted from secret_data.
* @param secret_data ASCII armored secret key.
* @return 1 on success, 0 on failure.
*/
int dc_preconfigure_keypair (dc_context_t* context, const char *addr, const char *public_data, const char *secret_data);
int dc_preconfigure_keypair (dc_context_t* context, const char *secret_data);
// handle chatlists
@@ -1553,30 +1547,6 @@ void dc_marknoticed_chat (dc_context_t* context, uint32_t ch
dc_array_t* dc_get_chat_media (dc_context_t* context, uint32_t chat_id, int msg_type, int msg_type2, int msg_type3);
/**
* Search next/previous message based on a given message and a list of types.
* Typically used to implement the "next" and "previous" buttons
* in a gallery or in a media player.
*
* @deprecated Deprecated 2023-10-03, use dc_get_chat_media() and navigate the returned array instead.
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
* @param msg_id The ID of the current message from which the next or previous message should be searched.
* @param dir 1=get the next message, -1=get the previous one.
* @param msg_type The message type to search for.
* If 0, the message type from curr_msg_id is used.
* @param msg_type2 Alternative message type to search for. 0 to skip.
* @param msg_type3 Alternative message type to search for. 0 to skip.
* @return Returns the message ID that should be played next.
* The returned message is in the same chat as the given one
* and has one of the given types.
* Typically, this result is passed again to dc_get_next_media()
* later on the next swipe.
* If there is not next/previous message, the function returns 0.
*/
uint32_t dc_get_next_media (dc_context_t* context, uint32_t msg_id, int dir, int msg_type, int msg_type2, int msg_type3);
/**
* Set chat visibility to pinned, archived or normal.
*
@@ -2507,7 +2477,7 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_BACKUP 251
#define DC_QR_BACKUP2 252
#define DC_QR_WEBRTC_INSTANCE 260 // text1=domain, text2=instance pattern
#define DC_QR_SOCKS5_PROXY 270 // text1=host, text2=port
#define DC_QR_PROXY 271 // text1=address (e.g. "127.0.0.1:9050")
#define DC_QR_ADDR 320 // id=contact
#define DC_QR_TEXT 330 // text1=text
#define DC_QR_URL 332 // text1=URL

View File

@@ -835,8 +835,6 @@ pub unsafe extern "C" fn dc_maybe_network(context: *mut dc_context_t) {
#[no_mangle]
pub unsafe extern "C" fn dc_preconfigure_keypair(
context: *mut dc_context_t,
addr: *const libc::c_char,
_public_data: *const libc::c_char,
secret_data: *const libc::c_char,
) -> i32 {
if context.is_null() {
@@ -844,9 +842,8 @@ pub unsafe extern "C" fn dc_preconfigure_keypair(
return 0;
}
let ctx = &*context;
let addr = to_string_lossy(addr);
let secret_data = to_string_lossy(secret_data);
block_on(preconfigure_keypair(ctx, &addr, &secret_data))
block_on(preconfigure_keypair(ctx, &secret_data))
.context("Failed to save keypair")
.log_err(ctx)
.is_ok() as libc::c_int
@@ -1446,48 +1443,6 @@ pub unsafe extern "C" fn dc_get_chat_media(
})
}
#[no_mangle]
#[allow(deprecated)]
pub unsafe extern "C" fn dc_get_next_media(
context: *mut dc_context_t,
msg_id: u32,
dir: libc::c_int,
msg_type: libc::c_int,
or_msg_type2: libc::c_int,
or_msg_type3: libc::c_int,
) -> u32 {
if context.is_null() {
eprintln!("ignoring careless call to dc_get_next_media()");
return 0;
}
let direction = if dir < 0 {
chat::Direction::Backward
} else {
chat::Direction::Forward
};
let ctx = &*context;
let msg_type = from_prim(msg_type).expect(&format!("invalid msg_type = {msg_type}"));
let or_msg_type2 =
from_prim(or_msg_type2).expect(&format!("incorrect or_msg_type2 = {or_msg_type2}"));
let or_msg_type3 =
from_prim(or_msg_type3).expect(&format!("incorrect or_msg_type3 = {or_msg_type3}"));
block_on(async move {
chat::get_next_media(
ctx,
MsgId::new(msg_id),
direction,
msg_type,
or_msg_type2,
or_msg_type3,
)
.await
.map(|msg_id| msg_id.map(|id| id.to_u32()).unwrap_or_default())
.unwrap_or(0)
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_set_chat_visibility(
context: *mut dc_context_t,
@@ -4537,19 +4492,16 @@ pub unsafe extern "C" fn dc_provider_new_from_email_with_dns(
let addr = to_string_lossy(addr);
let ctx = &*context;
let socks5_enabled = block_on(async move {
ctx.get_config_bool(config::Config::Socks5Enabled)
.await
.context("Can't get config")
.log_err(ctx)
});
let proxy_enabled = block_on(ctx.get_config_bool(config::Config::ProxyEnabled))
.context("Can't get config")
.log_err(ctx);
match socks5_enabled {
Ok(socks5_enabled) => {
match proxy_enabled {
Ok(proxy_enabled) => {
match block_on(provider::get_provider_info_by_addr(
ctx,
addr.as_str(),
socks5_enabled,
proxy_enabled,
))
.log_err(ctx)
.unwrap_or_default()

View File

@@ -34,44 +34,41 @@ pub enum Meaning {
}
impl Lot {
pub fn get_text1(&self) -> Option<&str> {
pub fn get_text1(&self) -> Option<Cow<str>> {
match self {
Self::Summary(summary) => match &summary.prefix {
None => None,
Some(SummaryPrefix::Draft(text)) => Some(text),
Some(SummaryPrefix::Username(username)) => Some(username),
Some(SummaryPrefix::Me(text)) => Some(text),
Some(SummaryPrefix::Draft(text)) => Some(Cow::Borrowed(text)),
Some(SummaryPrefix::Username(username)) => Some(Cow::Borrowed(username)),
Some(SummaryPrefix::Me(text)) => Some(Cow::Borrowed(text)),
},
Self::Qr(qr) => match qr {
Qr::AskVerifyContact { .. } => None,
Qr::AskVerifyGroup { grpname, .. } => Some(grpname),
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::FprOk { .. } => None,
Qr::FprMismatch { .. } => None,
Qr::FprWithoutAddr { fingerprint, .. } => Some(fingerprint),
Qr::Account { domain } => Some(domain),
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
Qr::Account { domain } => Some(Cow::Borrowed(domain)),
Qr::Backup2 { .. } => None,
Qr::WebrtcInstance { domain, .. } => Some(domain),
Qr::Socks5Proxy { host, .. } => Some(host),
Qr::Addr { draft, .. } => draft.as_deref(),
Qr::Url { url } => Some(url),
Qr::Text { text } => Some(text),
Qr::WebrtcInstance { domain, .. } => Some(Cow::Borrowed(domain)),
Qr::Proxy { host, port, .. } => Some(Cow::Owned(format!("{host}:{port}"))),
Qr::Addr { draft, .. } => draft.as_deref().map(Cow::Borrowed),
Qr::Url { url } => Some(Cow::Borrowed(url)),
Qr::Text { text } => Some(Cow::Borrowed(text)),
Qr::WithdrawVerifyContact { .. } => None,
Qr::WithdrawVerifyGroup { grpname, .. } => Some(grpname),
Qr::WithdrawVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::ReviveVerifyContact { .. } => None,
Qr::ReviveVerifyGroup { grpname, .. } => Some(grpname),
Qr::Login { address, .. } => Some(address),
Qr::ReviveVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::Login { address, .. } => Some(Cow::Borrowed(address)),
},
Self::Error(err) => Some(err),
Self::Error(err) => Some(Cow::Borrowed(err)),
}
}
pub fn get_text2(&self) -> Option<Cow<str>> {
match self {
Self::Summary(summary) => Some(summary.truncated_text(160)),
Self::Qr(qr) => match qr {
Qr::Socks5Proxy { port, .. } => Some(Cow::Owned(format!("{port}"))),
_ => None,
},
Self::Qr(_) => None,
Self::Error(_) => None,
}
}
@@ -107,7 +104,7 @@ impl Lot {
Qr::Account { .. } => LotState::QrAccount,
Qr::Backup2 { .. } => LotState::QrBackup2,
Qr::WebrtcInstance { .. } => LotState::QrWebrtcInstance,
Qr::Socks5Proxy { .. } => LotState::QrSocks5Proxy,
Qr::Proxy { .. } => LotState::QrProxy,
Qr::Addr { .. } => LotState::QrAddr,
Qr::Url { .. } => LotState::QrUrl,
Qr::Text { .. } => LotState::QrText,
@@ -133,7 +130,7 @@ impl Lot {
Qr::Account { .. } => Default::default(),
Qr::Backup2 { .. } => Default::default(),
Qr::WebrtcInstance { .. } => Default::default(),
Qr::Socks5Proxy { .. } => Default::default(),
Qr::Proxy { .. } => Default::default(),
Qr::Addr { contact_id, .. } => contact_id.to_u32(),
Qr::Url { .. } => Default::default(),
Qr::Text { .. } => Default::default(),
@@ -188,8 +185,8 @@ pub enum LotState {
/// text1=domain, text2=instance pattern
QrWebrtcInstance = 260,
/// text1=host, text2=port
QrSocks5Proxy = 270,
/// text1=address, text2=protocol
QrProxy = 271,
/// id=contact
QrAddr = 320,

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "1.142.12"
version = "1.147.1"
description = "DeltaChat JSON-RPC API"
edition = "2021"
default-run = "deltachat-jsonrpc-server"
@@ -25,7 +25,7 @@ async-channel = { workspace = true }
futures = { workspace = true }
serde_json = { workspace = true }
yerpc = { workspace = true, features = ["anyhow_expose", "openrpc"] }
typescript-type-def = { version = "0.5.8", features = ["json_value"] }
typescript-type-def = { version = "0.5.12", features = ["json_value"] }
tokio = { workspace = true }
sanitize-filename = { workspace = true }
walkdir = "2.5.0"

View File

@@ -321,12 +321,12 @@ impl CommandApi {
) -> Result<Option<ProviderInfo>> {
let ctx = self.get_context(account_id).await?;
let socks5_enabled = ctx
.get_config_bool(deltachat::config::Config::Socks5Enabled)
let proxy_enabled = ctx
.get_config_bool(deltachat::config::Config::ProxyEnabled)
.await?;
let provider_info =
get_provider_info(&ctx, email.split('@').last().unwrap_or(""), socks5_enabled).await;
get_provider_info(&ctx, email.split('@').last().unwrap_or(""), proxy_enabled).await;
Ok(ProviderInfo::from_dc_type(provider_info))
}
@@ -1552,55 +1552,6 @@ impl CommandApi {
Ok(media.iter().map(|msg_id| msg_id.to_u32()).collect())
}
/// Search next/previous message based on a given message and a list of types.
/// Typically used to implement the "next" and "previous" buttons
/// in a gallery or in a media player.
///
/// one combined call for getting chat::get_next_media for both directions
/// the manual chat::get_next_media in only one direction is not exposed by the jsonrpc yet
///
/// Deprecated 2023-10-03, use `get_chat_media` method
/// and navigate the returned array instead.
#[allow(deprecated)]
async fn get_neighboring_chat_media(
&self,
account_id: u32,
msg_id: u32,
message_type: MessageViewtype,
or_message_type2: Option<MessageViewtype>,
or_message_type3: Option<MessageViewtype>,
) -> Result<(Option<u32>, Option<u32>)> {
let ctx = self.get_context(account_id).await?;
let msg_type: Viewtype = message_type.into();
let msg_type2: Viewtype = or_message_type2.map(|v| v.into()).unwrap_or_default();
let msg_type3: Viewtype = or_message_type3.map(|v| v.into()).unwrap_or_default();
let prev = chat::get_next_media(
&ctx,
MsgId::new(msg_id),
chat::Direction::Backward,
msg_type,
msg_type2,
msg_type3,
)
.await?
.map(|id| id.to_u32());
let next = chat::get_next_media(
&ctx,
MsgId::new(msg_id),
chat::Direction::Forward,
msg_type,
msg_type2,
msg_type3,
)
.await?
.map(|id| id.to_u32());
Ok((prev, next))
}
// ---------------------------------------------
// backup
// ---------------------------------------------

View File

@@ -640,7 +640,7 @@ pub struct MessageInfo {
error: Option<String>,
rfc724_mid: String,
server_urls: Vec<String>,
hop_info: Option<String>,
hop_info: String,
}
impl MessageInfo {

View File

@@ -41,11 +41,10 @@ pub enum QrObject {
domain: String,
instance_pattern: String,
},
Socks5Proxy {
Proxy {
url: String,
host: String,
port: u16,
user: Option<String>,
pass: Option<String>,
},
Addr {
contact_id: u32,
@@ -152,17 +151,7 @@ impl From<Qr> for QrObject {
domain,
instance_pattern,
},
Qr::Socks5Proxy {
host,
port,
user,
pass,
} => QrObject::Socks5Proxy {
host,
port,
user,
pass,
},
Qr::Proxy { url, host, port } => QrObject::Proxy { url, host, port },
Qr::Addr { contact_id, draft } => {
let contact_id = contact_id.to_u32();
QrObject::Addr { contact_id, draft }

View File

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

View File

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

View File

@@ -86,10 +86,7 @@ describe("online tests", function () {
null
);
const chatId = await dc.rpc.createChatByContactId(accountId1, contactId);
const eventPromise = Promise.race([
waitForEvent(dc, "MsgsChanged", accountId2),
waitForEvent(dc, "IncomingMsg", accountId2),
]);
const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2);
await dc.rpc.miscSendTextMessage(accountId1, chatId, "Hello");
const { chatId: chatIdOnAccountB } = await eventPromise;
@@ -119,10 +116,7 @@ describe("online tests", function () {
null
);
const chatId = await dc.rpc.createChatByContactId(accountId1, contactId);
const eventPromise = Promise.race([
waitForEvent(dc, "MsgsChanged", accountId2),
waitForEvent(dc, "IncomingMsg", accountId2),
]);
const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2);
dc.rpc.miscSendTextMessage(accountId1, chatId, "Hello2");
// wait for message from A
console.log("wait for message from A");
@@ -143,10 +137,7 @@ describe("online tests", function () {
);
expect(message.text).equal("Hello2");
// Send message back from B to A
const eventPromise2 = Promise.race([
waitForEvent(dc, "MsgsChanged", accountId1),
waitForEvent(dc, "IncomingMsg", accountId1),
]);
const eventPromise2 = waitForEvent(dc, "IncomingMsg", accountId1);
dc.rpc.miscSendTextMessage(accountId2, chatId, "super secret message");
// Check if answer arrives at A and if it is encrypted
await eventPromise2;

View File

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

View File

@@ -1,7 +1,9 @@
#![allow(clippy::format_push_string)]
extern crate dirs;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use std::str::FromStr;
use std::time::Duration;
@@ -355,6 +357,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
configure\n\
connect\n\
disconnect\n\
fetch\n\
connectivity\n\
maybenetwork\n\
housekeeping\n\
@@ -486,8 +489,14 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
}
"send-backup" => {
let provider = BackupProvider::prepare(&context).await?;
let qr = provider.qr();
println!("QR code: {}", format_backup(&qr)?);
let qr = format_backup(&provider.qr())?;
println!("QR code: {}", qr);
let output = Command::new("qrencode")
.args(["-t", "ansiutf8", qr.as_str(), "-o", "-"])
.output()
.expect("failed to execute process");
std::io::stdout().write_all(&output.stdout).unwrap();
std::io::stderr().write_all(&output.stderr).unwrap();
provider.await?;
}
"receive-backup" => {
@@ -1249,10 +1258,10 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
}
"providerinfo" => {
ensure!(!arg1.is_empty(), "Argument <addr> missing.");
let socks5_enabled = context
.get_config_bool(config::Config::Socks5Enabled)
let proxy_enabled = context
.get_config_bool(config::Config::ProxyEnabled)
.await?;
match provider::get_provider_info(&context, arg1, socks5_enabled).await {
match provider::get_provider_info(&context, arg1, proxy_enabled).await {
Some(info) => {
println!("Information for provider belonging to {arg1}:");
println!("status: {}", info.status as u32);

View File

@@ -168,7 +168,7 @@ const IMEX_COMMANDS: [&str; 13] = [
"stop",
];
const DB_COMMANDS: [&str; 10] = [
const DB_COMMANDS: [&str; 11] = [
"info",
"set",
"get",
@@ -176,6 +176,7 @@ const DB_COMMANDS: [&str; 10] = [
"configure",
"connect",
"disconnect",
"fetch",
"connectivity",
"maybenetwork",
"housekeeping",
@@ -417,6 +418,9 @@ async fn handle_cmd(
"disconnect" => {
ctx.stop_io().await;
}
"fetch" => {
ctx.background_fetch().await?;
}
"configure" => {
ctx.configure().await?;
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "1.142.12"
version = "1.147.1"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -13,10 +13,13 @@ classifiers = [
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Communications :: Chat",
"Topic :: Communications :: Email"
]

View File

@@ -71,11 +71,11 @@ def test_qr_securejoin(acfactory, protect, tmp_path):
alice2 = acfactory.get_unconfigured_account()
alice2.import_backup(files[0])
logging.info("Alice creates a verified group")
alice_chat = alice.create_group("Verified group", protect=protect)
logging.info("Alice creates a group")
alice_chat = alice.create_group("Group", protect=protect)
assert alice_chat.get_basic_snapshot().is_protected == protect
logging.info("Bob joins verified group")
logging.info("Bob joins the group")
qr_code = alice_chat.get_qr_code()
bob.secure_join(qr_code)
@@ -113,7 +113,7 @@ def test_qr_securejoin(acfactory, protect, tmp_path):
assert alice2_contact_bob_snapshot.is_verified
# The QR code token is synced, so alice2 must be able to handle join requests.
logging.info("Fiona joins verified group via alice2")
logging.info("Fiona joins the group via alice2")
alice.stop_io()
fiona.secure_join(qr_code)
alice2.wait_for_securejoin_inviter_success()

View File

@@ -433,7 +433,7 @@ def test_provider_info(rpc) -> None:
assert provider_info["id"] == "gmail"
# Disable MX record resolution.
rpc.set_config(account_id, "socks5_enabled", "1")
rpc.set_config(account_id, "proxy_enabled", "1")
provider_info = rpc.get_provider_info(account_id, "github.com")
assert provider_info is None

View File

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

View File

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

View File

@@ -19,17 +19,10 @@ ignore = [
# when upgrading.
# Please keep this list alphabetically sorted.
skip = [
{ name = "asn1-rs-derive", version = "0.4.0" },
{ name = "asn1-rs-impl", version = "0.1.0" },
{ name = "asn1-rs", version = "0.5.2" },
{ name = "async-channel", version = "1.9.0" },
{ name = "base64", version = "<0.21" },
{ name = "base64", version = "0.21.7" },
{ name = "bitflags", version = "1.3.2" },
{ name = "darling_core", version = "<0.14" },
{ name = "darling_macro", version = "<0.14" },
{ name = "darling", version = "<0.14" },
{ name = "der-parser", version = "8.2.0" },
{ name = "event-listener", version = "2.5.3" },
{ name = "event-listener", version = "4.0.3" },
{ name = "fastrand", version = "1.9.0" },
@@ -39,9 +32,7 @@ skip = [
{ name = "http-body", version = "0.4.6" },
{ name = "http", version = "0.2.12" },
{ name = "hyper", version = "0.14.28" },
{ name = "idna", version = "0.4.0" },
{ name = "nix", version = "0.26.4" },
{ name = "oid-registry", version = "0.6.1" },
{ name = "quick-error", version = "<2.0" },
{ name = "rand_chacha", version = "<0.3" },
{ name = "rand_core", version = "<0.6" },
@@ -49,31 +40,22 @@ skip = [
{ name = "redox_syscall", version = "0.3.5" },
{ name = "regex-automata", version = "0.1.10" },
{ name = "regex-syntax", version = "0.6.29" },
{ name = "rustls-pemfile", version = "1.0.4" },
{ name = "rustls", version = "0.21.11" },
{ name = "rustls-webpki", version = "0.101.7" },
{ name = "spin", version = "<0.9.6" },
{ name = "strsim", version = "0.10.0" },
{ name = "sync_wrapper", version = "0.1.2" },
{ name = "synstructure", version = "0.12.6" },
{ name = "syn", version = "1.0.109" },
{ name = "time", version = "<0.3" },
{ name = "tokio-rustls", version = "0.24.1" },
{ name = "wasi", version = "<0.11" },
{ name = "webpki-roots", version ="0.25.4" },
{ name = "windows_aarch64_gnullvm", version = "<0.52" },
{ name = "windows_aarch64_msvc", version = "<0.52" },
{ name = "windows-core", version = "<0.54.0" },
{ name = "windows_i686_gnu", version = "<0.52" },
{ name = "windows_i686_msvc", version = "<0.52" },
{ name = "windows-sys", version = "<0.52" },
{ name = "windows-sys", version = "<0.59" },
{ name = "windows-targets", version = "<0.52" },
{ name = "windows", version = "<0.54.0" },
{ name = "windows_x86_64_gnullvm", version = "<0.52" },
{ name = "windows_x86_64_gnu", version = "<0.52" },
{ name = "windows_x86_64_msvc", version = "<0.52" },
{ name = "winreg", version = "0.50.0" },
{ name = "x509-parser", version = "<0.16.0" },
]

View File

@@ -134,9 +134,9 @@ module.exports = {
DC_QR_FPR_OK: 210,
DC_QR_FPR_WITHOUT_ADDR: 230,
DC_QR_LOGIN: 520,
DC_QR_PROXY: 271,
DC_QR_REVIVE_VERIFYCONTACT: 510,
DC_QR_REVIVE_VERIFYGROUP: 512,
DC_QR_SOCKS5_PROXY: 270,
DC_QR_TEXT: 330,
DC_QR_URL: 332,
DC_QR_WEBRTC_INSTANCE: 260,

View File

@@ -134,9 +134,9 @@ export enum C {
DC_QR_FPR_OK = 210,
DC_QR_FPR_WITHOUT_ADDR = 230,
DC_QR_LOGIN = 520,
DC_QR_PROXY = 271,
DC_QR_REVIVE_VERIFYCONTACT = 510,
DC_QR_REVIVE_VERIFYGROUP = 512,
DC_QR_SOCKS5_PROXY = 270,
DC_QR_TEXT = 330,
DC_QR_URL = 332,
DC_QR_WEBRTC_INSTANCE = 260,

View File

@@ -475,47 +475,6 @@ export class Context extends EventEmitter {
return binding.dcn_get_msg_html(this.dcn_context, Number(messageId))
}
getNextMediaMessage(
messageId: number,
msgType1: number,
msgType2: number,
msgType3: number
) {
debug(
`getNextMediaMessage ${messageId} ${msgType1} ${msgType2} ${msgType3}`
)
return this._getNextMedia(messageId, 1, msgType1, msgType2, msgType3)
}
getPreviousMediaMessage(
messageId: number,
msgType1: number,
msgType2: number,
msgType3: number
) {
debug(
`getPreviousMediaMessage ${messageId} ${msgType1} ${msgType2} ${msgType3}`
)
return this._getNextMedia(messageId, -1, msgType1, msgType2, msgType3)
}
_getNextMedia(
messageId: number,
dir: number,
msgType1: number,
msgType2: number,
msgType3: number
): number {
return binding.dcn_get_next_media(
this.dcn_context,
Number(messageId),
dir,
msgType1 || 0,
msgType2 || 0,
msgType3 || 0
)
}
getSecurejoinQrCode(chatId: number): string {
debug(`getSecurejoinQrCode ${chatId}`)
return binding.dcn_get_securejoin_qr(this.dcn_context, Number(chatId))

View File

@@ -1053,27 +1053,6 @@ NAPI_METHOD(dcn_get_msg_html) {
NAPI_RETURN_AND_UNREF_STRING(msg_html);
}
NAPI_METHOD(dcn_get_next_media) {
NAPI_ARGV(6);
NAPI_DCN_CONTEXT();
NAPI_ARGV_UINT32(msg_id, 1);
NAPI_ARGV_INT32(dir, 2);
NAPI_ARGV_INT32(msg_type1, 3);
NAPI_ARGV_INT32(msg_type2, 4);
NAPI_ARGV_INT32(msg_type3, 5);
//TRACE("calling..");
uint32_t next_id = dc_get_next_media(dcn_context->dc_context,
msg_id,
dir,
msg_type1,
msg_type2,
msg_type3);
//TRACE("result %d", next_id);
NAPI_RETURN_UINT32(next_id);
}
NAPI_METHOD(dcn_set_chat_visibility) {
NAPI_ARGV(3);
NAPI_DCN_CONTEXT();
@@ -3443,7 +3422,6 @@ NAPI_INIT() {
NAPI_EXPORT_FUNCTION(dcn_get_msg_cnt);
NAPI_EXPORT_FUNCTION(dcn_get_msg_info);
NAPI_EXPORT_FUNCTION(dcn_get_msg_html);
NAPI_EXPORT_FUNCTION(dcn_get_next_media);
NAPI_EXPORT_FUNCTION(dcn_set_chat_visibility);
NAPI_EXPORT_FUNCTION(dcn_get_securejoin_qr);
NAPI_EXPORT_FUNCTION(dcn_get_securejoin_qr_svg);

View File

@@ -271,7 +271,7 @@ describe('Basic offline Tests', function () {
'sync_msgs',
'sentbox_watch',
'show_emails',
'socks5_enabled',
'proxy_enabled',
'sqlite_version',
'uptime',
'used_account_settings',

View File

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

View File

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

View File

@@ -194,15 +194,13 @@ class Account:
assert res != ffi.NULL, f"config value not found for: {name!r}"
return from_dc_charpointer(res)
def _preconfigure_keypair(self, addr: str, secret: str) -> None:
def _preconfigure_keypair(self, secret: str) -> None:
"""See dc_preconfigure_keypair() in deltachat.h.
In other words, you don't need this.
"""
res = lib.dc_preconfigure_keypair(
self._dc_context,
as_dc_charpointer(addr),
ffi.NULL,
as_dc_charpointer(secret),
)
if res == 0:

View File

@@ -462,7 +462,7 @@ class ACFactory:
def remove_preconfigured_keys(self) -> None:
self._preconfigured_keys = []
def _preconfigure_key(self, account, addr):
def _preconfigure_key(self, account):
# Only set a preconfigured key if we haven't used it yet for another account.
try:
keyname = self._preconfigured_keys.pop(0)
@@ -471,9 +471,9 @@ class ACFactory:
else:
fname_sec = self.data.read_path(f"key/{keyname}-secret.asc")
if fname_sec:
account._preconfigure_keypair(addr, fname_sec)
account._preconfigure_keypair(fname_sec)
return True
print(f"WARN: could not use preconfigured keys for {addr!r}")
print("WARN: could not use preconfigured keys")
def get_pseudo_configured_account(self, passphrase: Optional[str] = None) -> Account:
# do a pseudo-configured account
@@ -492,7 +492,7 @@ class ACFactory:
"configured": "1",
},
)
self._preconfigure_key(ac, addr)
self._preconfigure_key(ac)
self._acsetup.init_logging(ac)
return ac
@@ -525,9 +525,10 @@ class ACFactory:
configdict.setdefault("mvbox_move", False)
configdict.setdefault("sentbox_watch", False)
configdict.setdefault("sync_msgs", False)
configdict.setdefault("delete_server_after", 0)
ac.update_config(configdict)
self._acsetup._account2config[ac] = configdict
self._preconfigure_key(ac, configdict["addr"])
self._preconfigure_key(ac)
return ac
def wait_configured(self, account) -> None:

View File

@@ -488,10 +488,18 @@ def test_move_sync_msgs(acfactory):
ac1 = acfactory.new_online_configuring_account(bcc_self=True, sync_msgs=True, fix_is_chatmail=True)
acfactory.bring_accounts_online()
ac1.direct_imap.select_folder("DeltaChat")
# Sync messages may also be sent during the configuration.
mvbox_msg_cnt = len(ac1.direct_imap.get_all_messages())
ac1.set_config("displayname", "Alice")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
ac1._evtracker.get_matching("DC_EVENT_MSG_DELIVERED")
ac1.set_config("displayname", "Bob")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
ac1._evtracker.get_matching("DC_EVENT_MSG_DELIVERED")
ac1.direct_imap.select_folder("Inbox")
assert len(ac1.direct_imap.get_all_messages()) == 0
ac1.direct_imap.select_folder("DeltaChat")
assert len(ac1.direct_imap.get_all_messages()) == mvbox_msg_cnt + 2
def test_forward_messages(acfactory, lp):
@@ -620,7 +628,7 @@ def test_long_group_name(acfactory, lp):
def test_send_self_message(acfactory, lp):
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
ac1 = acfactory.new_online_configuring_account(mvbox_move=True, bcc_self=True)
acfactory.bring_accounts_online()
lp.sec("ac1: create self chat")
chat = ac1.get_self_contact().create_chat()
@@ -2076,12 +2084,11 @@ def test_send_receive_locations(acfactory, lp):
def test_immediate_autodelete(acfactory, lp):
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
# "1" means delete immediately, while "0" means do not delete
ac2.set_config("delete_server_after", "1")
acfactory.bring_accounts_online()
lp.sec("ac1: create chat with ac2")
chat1 = ac1.create_chat(ac2)
ac2.create_chat(ac1)

View File

@@ -67,7 +67,7 @@ class TestOfflineAccountBasic:
ac = acfactory.get_unconfigured_account()
alice_secret = data.read_path("key/alice-secret.asc")
assert alice_secret
ac._preconfigure_keypair("alice@example.org", alice_secret)
ac._preconfigure_keypair(alice_secret)
def test_getinfo(self, acfactory):
ac1 = acfactory.get_unconfigured_account()

View File

@@ -1 +1 @@
2024-09-02
2024-10-13

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.80.1
RUST_VERSION=1.81.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

View File

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

View File

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

View File

@@ -253,16 +253,16 @@ impl<'a> BlobObject<'a> {
///
/// The extension part will always be lowercased.
fn sanitise_name(name: &str) -> (String, String) {
let mut name = name.to_string();
let mut name = name;
for part in name.rsplit('/') {
if !part.is_empty() {
name = part.to_string();
name = part;
break;
}
}
for part in name.rsplit('\\') {
if !part.is_empty() {
name = part.to_string();
name = part;
break;
}
}
@@ -272,32 +272,39 @@ impl<'a> BlobObject<'a> {
replacement: "",
};
let clean = sanitize_filename::sanitize_with_options(name, opts);
// Let's take the tricky filename
let name = sanitize_filename::sanitize_with_options(name, opts);
// Let's take a tricky filename,
// "file.with_lots_of_characters_behind_point_and_double_ending.tar.gz" as an example.
// Split it into "file" and "with_lots_of_characters_behind_point_and_double_ending.tar.gz":
let mut iter = clean.splitn(2, '.');
let stem: String = iter.next().unwrap_or_default().chars().take(64).collect();
// stem == "file"
let ext_chars = iter.next().unwrap_or_default().chars();
let ext: String = ext_chars
// Assume that the extension is 32 chars maximum.
let ext: String = name
.chars()
.rev()
.take(32)
.take_while(|c| !c.is_whitespace())
.take(33)
.collect::<Vec<_>>()
.iter()
.rev()
.collect();
// ext == "d_point_and_double_ending.tar.gz"
// ext == "nd_point_and_double_ending.tar.gz"
if ext.is_empty() {
(stem, "".to_string())
// Split it into "nd_point_and_double_ending" and "tar.gz":
let mut iter = ext.splitn(2, '.');
iter.next();
let ext = iter.next().unwrap_or_default();
let ext = if ext.is_empty() {
String::new()
} else {
(stem, format!(".{ext}").to_lowercase())
// Return ("file", ".d_point_and_double_ending.tar.gz")
// which is not perfect but acceptable.
}
format!(".{ext}")
// ".tar.gz"
};
let stem = name
.strip_suffix(&ext)
.unwrap_or_default()
.chars()
.take(64)
.collect();
(stem, ext.to_lowercase())
}
/// Checks whether a name is a valid blob name.
@@ -666,10 +673,6 @@ impl<'a> BlobDirContents<'a> {
pub(crate) fn iter(&self) -> BlobDirIter<'_> {
BlobDirIter::new(self.context, self.inner.iter())
}
pub(crate) fn len(&self) -> usize {
self.inner.len()
}
}
/// A iterator over all the [`BlobObject`]s in the blobdir.
@@ -967,6 +970,19 @@ mod tests {
assert!(!stem.contains(':'));
assert!(!stem.contains('*'));
assert!(!stem.contains('?'));
let (stem, ext) = BlobObject::sanitise_name(
"file.with_lots_of_characters_behind_point_and_double_ending.tar.gz",
);
assert_eq!(
stem,
"file.with_lots_of_characters_behind_point_and_double_ending"
);
assert_eq!(ext, ".tar.gz");
let (stem, ext) = BlobObject::sanitise_name("a. tar.tar.gz");
assert_eq!(stem, "a. tar");
assert_eq!(ext, ".tar.gz");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -46,8 +46,8 @@ use crate::stock_str;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
buf_compress, create_id, create_outgoing_rfc724_mid, create_smeared_timestamp,
create_smeared_timestamps, get_abs_path, gm2local_offset, smeared_time, time, IsNoneOrEmpty,
SystemTime,
create_smeared_timestamps, get_abs_path, gm2local_offset, smeared_time, time,
truncate_msg_text, IsNoneOrEmpty, SystemTime,
};
use crate::webxdc::StatusUpdateSerial;
@@ -279,9 +279,10 @@ impl ChatId {
) -> Result<Self> {
let chat_id = match ChatIdBlocked::lookup_by_contact(context, contact_id).await? {
Some(chat) => {
if create_blocked == Blocked::Not && chat.blocked != Blocked::Not {
chat.id.set_blocked(context, Blocked::Not).await?;
if create_blocked != Blocked::Not || chat.blocked == Blocked::Not {
return Ok(chat.id);
}
chat.id.set_blocked(context, Blocked::Not).await?;
chat.id
}
None => {
@@ -1041,7 +1042,13 @@ impl ChatId {
pub(crate) async fn get_timestamp(self, context: &Context) -> Result<Option<i64>> {
let timestamp = context
.sql
.query_get_value("SELECT MAX(timestamp) FROM msgs WHERE chat_id=?", (self,))
.query_get_value(
"SELECT MAX(timestamp)
FROM msgs
WHERE chat_id=?
HAVING COUNT(*) > 0",
(self,),
)
.await?;
Ok(timestamp)
}
@@ -1227,6 +1234,7 @@ impl ChatId {
AND ((state BETWEEN {} AND {}) OR (state >= {})) \
AND NOT hidden \
AND download_state={} \
AND from_id != {} \
ORDER BY timestamp DESC, id DESC \
LIMIT 1;",
MessageState::InFresh as u32,
@@ -1235,6 +1243,9 @@ impl ChatId {
// Do not reply to not fully downloaded messages. Such a message could be a group chat
// message that we assigned to 1:1 chat.
DownloadState::Done as u32,
// Do not reference info messages, they are not actually sent out
// and have Message-IDs unknown to other chat members.
ContactId::INFO.to_u32(),
);
sql.query_row_optional(&query, (self,), f).await
}
@@ -1246,7 +1257,7 @@ impl ChatId {
) -> Result<Option<(String, String, String)>> {
self.parent_query(
context,
"rfc724_mid, mime_in_reply_to, mime_references",
"rfc724_mid, mime_in_reply_to, IFNULL(mime_references, '')",
state_out_min,
|row: &rusqlite::Row| {
let rfc724_mid: String = row.get(0)?;
@@ -1400,7 +1411,10 @@ impl ChatId {
context
.sql
.query_get_value(
"SELECT MAX(timestamp) FROM msgs WHERE chat_id=? AND state!=?",
"SELECT MAX(timestamp)
FROM msgs
WHERE chat_id=? AND state!=?
HAVING COUNT(*) > 0",
(self, MessageState::OutDraft),
)
.await?
@@ -1416,7 +1430,10 @@ impl ChatId {
context
.sql
.query_get_value(
"SELECT MAX(timestamp) FROM msgs WHERE chat_id=? AND hidden=0 AND state>?",
"SELECT MAX(timestamp)
FROM msgs
WHERE chat_id=? AND hidden=0 AND state>?
HAVING COUNT(*) > 0",
(self, MessageState::InFresh),
)
.await?
@@ -1934,11 +1951,13 @@ impl Chat {
msg.param.set_int(Param::AttachGroupImage, 1);
self.param.remove(Param::Unpromoted);
self.update_param(context).await?;
// send_sync_msg() is called a moment later at `smtp::send_smtp_messages()`
// when the group creation message is already in the `smtp` table --
// this makes sure, the other devices are aware of grpid that is used in the sync-message.
// TODO: Remove this compat code needed because Core <= v1.143:
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also
// send them when the group is promoted.
// - doesn't sync QR code tokens for unpromoted groups and the group might be created
// before an upgrade.
context
.sync_qr_code_tokens(Some(self.id))
.sync_qr_code_tokens(Some(self.grpid.as_str()))
.await
.log_err(context)
.ok();
@@ -2071,6 +2090,8 @@ impl Chat {
msg.from_id = ContactId::SELF;
msg.rfc724_mid = new_rfc724_mid;
msg.timestamp_sort = timestamp;
let (msg_text, was_truncated) = truncate_msg_text(context, msg.text.clone()).await?;
let mime_modified = new_mime_headers.is_some() | was_truncated;
// add message to the database
if let Some(update_msg_id) = update_msg_id {
@@ -2092,14 +2113,14 @@ impl Chat {
msg.timestamp_sort,
msg.viewtype,
msg.state,
msg.text,
message::normalize_text(&msg.text),
msg_text,
message::normalize_text(&msg_text),
&msg.subject,
msg.param.to_string(),
msg.hidden,
msg.in_reply_to.as_deref().unwrap_or_default(),
new_references,
new_mime_headers.is_some(),
mime_modified,
new_mime_headers.unwrap_or_default(),
location_id as i32,
ephemeral_timer,
@@ -2143,14 +2164,14 @@ impl Chat {
msg.timestamp_sort,
msg.viewtype,
msg.state,
msg.text,
message::normalize_text(&msg.text),
msg_text,
message::normalize_text(&msg_text),
&msg.subject,
msg.param.to_string(),
msg.hidden,
msg.in_reply_to.as_deref().unwrap_or_default(),
new_references,
new_mime_headers.is_some(),
mime_modified,
new_mime_headers.unwrap_or_default(),
location_id as i32,
ephemeral_timer,
@@ -2240,7 +2261,7 @@ pub(crate) async fn sync(context: &Context, id: SyncId, action: SyncAction) -> R
context
.add_sync_item(SyncData::AlterChat { id, action })
.await?;
context.scheduler.interrupt_smtp().await;
context.scheduler.interrupt_inbox().await;
Ok(())
}
@@ -2901,10 +2922,9 @@ async fn prepare_send_msg(
create_send_msg_jobs(context, msg).await
}
/// Constructs jobs for sending a message and inserts them into the `smtp` table.
/// Constructs jobs for sending a message and inserts them into the appropriate table.
///
/// Returns row ids if jobs were created or an empty `Vec` otherwise, e.g. when sending to a
/// group with only self and no BCC-to-self configured.
/// 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.
pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Vec<i64>> {
@@ -2998,12 +3018,6 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
}
}
if let Some(sync_ids) = rendered_msg.sync_ids_to_delete {
if let Err(err) = context.delete_sync_ids(sync_ids).await {
error!(context, "Failed to delete sync ids: {err:#}.");
}
}
if attach_selfavatar {
if let Err(err) = msg.chat_id.set_selfavatar_timestamp(context, now).await {
error!(context, "Failed to set selfavatar timestamp: {err:#}.");
@@ -3020,19 +3034,30 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
let chunk_size = context.get_max_smtp_rcpt_to().await?;
let trans_fn = |t: &mut rusqlite::Transaction| {
let mut row_ids = Vec::<i64>::new();
for recipients_chunk in recipients.chunks(chunk_size) {
let recipients_chunk = recipients_chunk.join(" ");
let row_id = t.execute(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id) \
VALUES (?1, ?2, ?3, ?4)",
(
&rendered_msg.rfc724_mid,
recipients_chunk,
&rendered_msg.message,
msg.id,
),
if let Some(sync_ids) = rendered_msg.sync_ids_to_delete {
t.execute(
&format!("DELETE FROM multi_device_sync WHERE id IN ({sync_ids})"),
(),
)?;
row_ids.push(row_id.try_into()?);
t.execute(
"INSERT INTO imap_send (mime, msg_id) VALUES (?, ?)",
(&rendered_msg.message, msg.id),
)?;
} else {
for recipients_chunk in recipients.chunks(chunk_size) {
let recipients_chunk = recipients_chunk.join(" ");
let row_id = t.execute(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id) \
VALUES (?1, ?2, ?3, ?4)",
(
&rendered_msg.rfc724_mid,
recipients_chunk,
&rendered_msg.message,
msg.id,
),
)?;
row_ids.push(row_id.try_into()?);
}
}
Ok(row_ids)
};
@@ -3407,65 +3432,6 @@ pub async fn get_chat_media(
Ok(list)
}
/// Indicates the direction over which to iterate.
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(i32)]
pub enum Direction {
/// Search forward.
Forward = 1,
/// Search backward.
Backward = -1,
}
/// Searches next/previous message based on the given message and list of types.
///
/// Deprecated since 2023-10-03.
#[deprecated(note = "use `get_chat_media` instead")]
pub async fn get_next_media(
context: &Context,
curr_msg_id: MsgId,
direction: Direction,
msg_type: Viewtype,
msg_type2: Viewtype,
msg_type3: Viewtype,
) -> Result<Option<MsgId>> {
let mut ret: Option<MsgId> = None;
if let Ok(msg) = Message::load_from_db(context, curr_msg_id).await {
let list: Vec<MsgId> = get_chat_media(
context,
Some(msg.chat_id),
if msg_type != Viewtype::Unknown {
msg_type
} else {
msg.viewtype
},
msg_type2,
msg_type3,
)
.await?;
for (i, msg_id) in list.iter().enumerate() {
if curr_msg_id == *msg_id {
match direction {
Direction::Forward => {
if i + 1 < list.len() {
ret = list.get(i + 1).copied();
}
}
Direction::Backward => {
if i >= 1 {
ret = list.get(i - 1).copied();
}
}
}
break;
}
}
}
Ok(ret)
}
/// Returns a vector of contact IDs for given chat ID.
pub async fn get_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec<ContactId>> {
// Normal chats do not include SELF. Group chats do (as it may happen that one is deleted from a
@@ -3776,14 +3742,19 @@ pub(crate) async fn add_contact_to_chat_ex(
return Err(e);
}
sync = Nosync;
// TODO: Remove this compat code needed because Core <= v1.143:
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also send
// them when the group is promoted.
// - doesn't sync QR code tokens for unpromoted groups and the group might be created before
// an upgrade.
if sync_qr_code_tokens
&& context
.sync_qr_code_tokens(Some(chat_id))
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await
.log_err(context)
.is_ok()
{
context.scheduler.interrupt_smtp().await;
context.scheduler.interrupt_inbox().await;
}
}
context.emit_event(EventType::ChatModified(chat_id));
@@ -4259,10 +4230,14 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
msg.update_param(context).await?;
}
match msg.get_state() {
MessageState::OutFailed | MessageState::OutDelivered | MessageState::OutMdnRcvd => {
// `get_state()` may return an outdated `OutPending`, so update anyway.
MessageState::OutPending
| MessageState::OutFailed
| MessageState::OutDelivered
| MessageState::OutMdnRcvd => {
message::update_msg_state(context, msg.id, MessageState::OutPending).await?
}
_ => bail!("unexpected message state"),
msg_state => bail!("Unexpected message state {msg_state}"),
}
context.emit_event(EventType::MsgsChanged {
chat_id: msg.chat_id,
@@ -4382,7 +4357,10 @@ pub async fn add_device_msg_with_importance(
if let Some(last_msg_time) = context
.sql
.query_get_value(
"SELECT MAX(timestamp) FROM msgs WHERE chat_id=?",
"SELECT MAX(timestamp)
FROM msgs
WHERE chat_id=?
HAVING COUNT(*) > 0",
(chat_id,),
)
.await?
@@ -4708,6 +4686,7 @@ mod tests {
use super::*;
use crate::chatlist::get_archived_cnt;
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
use crate::headerdef::HeaderDef;
use crate::message::delete_msgs;
use crate::receive_imf::receive_imf;
use crate::test_utils::{sync, TestContext, TestContextManager};
@@ -6274,11 +6253,10 @@ mod tests {
// Alice has an SMTP-server replacing the `Message-ID:`-header (as done eg. by outlook.com).
let sent_msg = alice.pop_sent_msg().await;
let msg = sent_msg.payload();
assert_eq!(msg.match_indices("Message-ID: <Mr.").count(), 2);
assert_eq!(msg.match_indices("References: <Mr.").count(), 1);
let msg = msg.replace("Message-ID: <Mr.", "Message-ID: <XXX");
assert_eq!(msg.match_indices("Message-ID: <Mr.").count(), 0);
assert_eq!(msg.match_indices("References: <Mr.").count(), 1);
assert_eq!(msg.match_indices("Message-ID: <").count(), 2);
assert_eq!(msg.match_indices("References: <").count(), 1);
let msg = msg.replace("Message-ID: <", "Message-ID: <X.X");
assert_eq!(msg.match_indices("References: <").count(), 1);
// Bob receives this message, he may detect group by `References:`- or `Chat-Group:`-header
receive_imf(&bob, msg.as_bytes(), false).await.unwrap();
@@ -6295,7 +6273,7 @@ mod tests {
send_text_msg(&bob, bob_chat.id, "ho!".to_string()).await?;
let sent_msg = bob.pop_sent_msg().await;
let msg = sent_msg.payload();
let msg = msg.replace("Message-ID: <Mr.", "Message-ID: <XXX");
let msg = msg.replace("Message-ID: <", "Message-ID: <X.X");
let msg = msg.replace("Chat-", "XXXX-");
assert_eq!(msg.match_indices("Chat-").count(), 0);
@@ -6845,8 +6823,29 @@ mod tests {
)
.await?;
let sent2 = alice.pop_sent_msg().await;
resend_msgs(&alice, &[sent1.sender_msg_id]).await?;
let resent_msg_id = sent1.sender_msg_id;
resend_msgs(&alice, &[resent_msg_id]).await?;
assert_eq!(
resent_msg_id.get_state(&alice).await?,
MessageState::OutPending
);
resend_msgs(&alice, &[resent_msg_id]).await?;
// Message can be re-sent multiple times.
assert_eq!(
resent_msg_id.get_state(&alice).await?,
MessageState::OutPending
);
alice.pop_sent_msg().await;
// There's still one more pending SMTP job.
assert_eq!(
resent_msg_id.get_state(&alice).await?,
MessageState::OutPending
);
let sent3 = alice.pop_sent_msg().await;
assert_eq!(
resent_msg_id.get_state(&alice).await?,
MessageState::OutDelivered
);
// Bob receives all messages
let bob = TestContext::new_bob().await;
@@ -7643,4 +7642,29 @@ mod tests {
Ok(())
}
/// Tests that info message is ignored when constructing `In-Reply-To`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_info_not_referenced() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_received_message = tcm.send_recv_accept(alice, bob, "Hi!").await;
let bob_chat_id = bob_received_message.chat_id;
add_info_msg(bob, bob_chat_id, "Some info", create_smeared_timestamp(bob)).await?;
// Bob sends a message.
// This message should reference Alice's "Hi!" message and not the info message.
let sent = bob.send_text(bob_chat_id, "Hi hi!").await;
let mime_message = alice.parse_msg(&sent).await;
let in_reply_to = mime_message.get_header(HeaderDef::InReplyTo).unwrap();
assert_eq!(
in_reply_to,
format!("<{}>", bob_received_message.rfc724_mid)
);
Ok(())
}
}

View File

@@ -91,21 +91,44 @@ pub enum Config {
/// Should not be extended in the future, create new config keys instead.
ServerFlags,
/// True if proxy is enabled.
///
/// Can be used to disable proxy without erasing known URLs.
ProxyEnabled,
/// Proxy URL.
///
/// Supported URLs schemes are `http://` (HTTP), `https://` (HTTPS),
/// `socks5://` (SOCKS5) and `ss://` (Shadowsocks).
///
/// May contain multiple URLs separated by newline, in which case the first one is used.
ProxyUrl,
/// True if SOCKS5 is enabled.
///
/// Can be used to disable SOCKS5 without erasing SOCKS5 configuration.
///
/// Deprecated in favor of `ProxyEnabled`.
Socks5Enabled,
/// SOCKS5 proxy server hostname or address.
///
/// Deprecated in favor of `ProxyUrl`.
Socks5Host,
/// SOCKS5 proxy server port.
///
/// Deprecated in favor of `ProxyUrl`.
Socks5Port,
/// SOCKS5 proxy server username.
///
/// Deprecated in favor of `ProxyUrl`.
Socks5User,
/// SOCKS5 proxy server password.
///
/// Deprecated in favor of `ProxyUrl`.
Socks5Password,
/// Own name to use in the `From:` field when sending messages.
@@ -174,12 +197,12 @@ pub enum Config {
/// Timer in seconds after which the message is deleted from the
/// server.
///
/// Equals to 0 by default, which means the message is never
/// deleted.
/// 0 means messages are never deleted by Delta Chat.
///
/// Value 1 is treated as "delete at once": messages are deleted
/// immediately, without moving to DeltaChat folder.
#[strum(props(default = "0"))]
///
/// Default is 1 for chatmail accounts before a backup export, 0 otherwise.
DeleteServerAfter,
/// Timer in seconds after which the message is deleted from the
@@ -438,13 +461,16 @@ impl Config {
}
impl Context {
/// Returns true if configuration value is set for the given key.
pub async fn config_exists(&self, key: Config) -> Result<bool> {
/// Returns true if configuration value is set in the db for the given key.
///
/// NB: Don't use this to check if the key is configured because this doesn't look into
/// environment. The proper use of this function is e.g. checking a key before setting it.
pub(crate) async fn config_exists(&self, key: Config) -> Result<bool> {
Ok(self.sql.get_raw_config(key.as_ref()).await?.is_some())
}
/// Get a configuration key. Returns `None` if no value is set, and no default value found.
pub async fn get_config(&self, key: Config) -> Result<Option<String>> {
/// Get a config key value. Returns `None` if no value is set.
pub(crate) async fn get_config_opt(&self, key: Config) -> Result<Option<String>> {
let env_key = format!("DELTACHAT_{}", key.as_ref().to_uppercase());
if let Ok(value) = env::var(env_key) {
return Ok(Some(value));
@@ -464,19 +490,38 @@ impl Context {
Config::SysConfigKeys => Some(get_config_keys_string()),
_ => self.sql.get_raw_config(key.as_ref()).await?,
};
Ok(value)
}
/// Get a config key value if set, or a default value. Returns `None` if no value exists.
pub async fn get_config(&self, key: Config) -> Result<Option<String>> {
let value = self.get_config_opt(key).await?;
if value.is_some() {
return Ok(value);
}
// Default values
match key {
Config::ConfiguredInboxFolder => Ok(Some("INBOX".to_owned())),
_ => Ok(key.get_str("default").map(|s| s.to_string())),
}
let val = match key {
Config::ConfiguredInboxFolder => Some("INBOX"),
Config::DeleteServerAfter => match Box::pin(self.is_chatmail()).await? {
false => Some("0"),
true => Some("1"),
},
_ => key.get_str("default"),
};
Ok(val.map(|s| s.to_string()))
}
/// Returns Some(T) if a value for the given key exists and was successfully parsed.
/// Returns Some(T) if a value for the given key is set and was successfully parsed.
/// Returns None if could not parse.
pub(crate) async fn get_config_opt_parsed<T: FromStr>(&self, key: Config) -> Result<Option<T>> {
self.get_config_opt(key)
.await
.map(|s: Option<String>| s.and_then(|s| s.parse().ok()))
}
/// Returns Some(T) if a value for the given key exists (incl. default value) and was
/// successfully parsed.
/// Returns None if could not parse.
pub async fn get_config_parsed<T: FromStr>(&self, key: Config) -> Result<Option<T>> {
self.get_config(key)
@@ -504,14 +549,21 @@ impl Context {
Ok(self.get_config_parsed(key).await?.unwrap_or_default())
}
/// Returns boolean configuration value (if any) for the given key.
pub async fn get_config_bool_opt(&self, key: Config) -> Result<Option<bool>> {
Ok(self.get_config_parsed::<i32>(key).await?.map(|x| x != 0))
/// Returns boolean configuration value (if set) for the given key.
pub(crate) async fn get_config_bool_opt(&self, key: Config) -> Result<Option<bool>> {
Ok(self
.get_config_opt_parsed::<i32>(key)
.await?
.map(|x| x != 0))
}
/// Returns boolean configuration value for the given key.
pub async fn get_config_bool(&self, key: Config) -> Result<bool> {
Ok(self.get_config_bool_opt(key).await?.unwrap_or_default())
Ok(self
.get_config_parsed::<i32>(key)
.await?
.map(|x| x != 0)
.unwrap_or_default())
}
/// Returns true if movebox ("DeltaChat" folder) should be watched.
@@ -537,11 +589,17 @@ impl Context {
&& !self.get_config_bool(Config::Bot).await?)
}
/// Returns whether sync messages should be uploaded to the mvbox.
pub(crate) async fn should_move_sync_msgs(&self) -> Result<bool> {
Ok(self.get_config_bool(Config::MvboxMove).await?
|| !self.get_config_bool(Config::IsChatmail).await?)
}
/// Returns whether MDNs should be requested.
pub(crate) async fn should_request_mdns(&self) -> Result<bool> {
match self.config_exists(Config::MdnsEnabled).await? {
true => self.get_config_bool(Config::MdnsEnabled).await,
false => Ok(!self.get_config_bool(Config::Bot).await?),
match self.get_config_bool_opt(Config::MdnsEnabled).await? {
Some(val) => Ok(val),
None => Ok(!self.get_config_bool(Config::Bot).await?),
}
}
@@ -555,11 +613,16 @@ impl Context {
/// `None` means never delete the message, `Some(0)` means delete
/// at once, `Some(x)` means delete after `x` seconds.
pub async fn get_config_delete_server_after(&self) -> Result<Option<i64>> {
match self.get_config_int(Config::DeleteServerAfter).await? {
0 => Ok(None),
1 => Ok(Some(0)),
x => Ok(Some(i64::from(x))),
}
let val = match self
.get_config_parsed::<i64>(Config::DeleteServerAfter)
.await?
.unwrap_or(0)
{
0 => None,
1 => Some(0),
x => Some(x),
};
Ok(val)
}
/// Gets the configured provider, as saved in the `configured_provider` value.
@@ -604,6 +667,7 @@ impl Context {
fn check_config(key: Config, value: Option<&str>) -> Result<()> {
match key {
Config::Socks5Enabled
| Config::ProxyEnabled
| Config::BccSelf
| Config::E2eeEnabled
| Config::MdnsEnabled
@@ -734,7 +798,7 @@ impl Context {
{
return Ok(());
}
self.scheduler.interrupt_smtp().await;
self.scheduler.interrupt_inbox().await;
Ok(())
}
@@ -793,6 +857,8 @@ impl Context {
///
/// This should only be used by test code and during configure.
pub(crate) async fn set_primary_self_addr(&self, primary_new: &str) -> Result<()> {
self.quota.write().await.take();
// add old primary address (if exists) to secondary addresses
let mut secondary_addrs = self.get_all_self_addrs().await?;
// never store a primary address also as a secondary
@@ -805,7 +871,7 @@ impl Context {
self.set_config_internal(Config::ConfiguredAddr, Some(primary_new))
.await?;
self.emit_event(EventType::ConnectivityChanged);
Ok(())
}
@@ -1004,12 +1070,14 @@ mod tests {
let t = &TestContext::new_alice().await;
assert!(t.should_request_mdns().await?);
assert!(t.should_send_mdns().await?);
assert!(t.get_config_bool_opt(Config::MdnsEnabled).await?.is_none());
// The setting should be displayed correctly.
assert!(t.get_config_bool(Config::MdnsEnabled).await?);
t.set_config_bool(Config::Bot, true).await?;
assert!(!t.should_request_mdns().await?);
assert!(t.should_send_mdns().await?);
assert!(t.get_config_bool_opt(Config::MdnsEnabled).await?.is_none());
assert!(t.get_config_bool(Config::MdnsEnabled).await?);
Ok(())
}
@@ -1115,7 +1183,7 @@ mod tests {
let status = "Synced via usual message";
alice0.set_config(Config::Selfstatus, Some(status)).await?;
alice0.send_sync_msg().await?;
alice0.pop_sent_msg().await;
alice0.pop_sent_sync_msg().await;
let status1 = "Synced via sync message";
alice1.set_config(Config::Selfstatus, Some(status1)).await?;
tcm.send_recv(alice0, alice1, "hi Alice!").await;
@@ -1139,7 +1207,7 @@ mod tests {
.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
.await?;
alice0.send_sync_msg().await?;
alice0.pop_sent_msg().await;
alice0.pop_sent_sync_msg().await;
let file = alice1.dir.path().join("avatar.jpg");
let bytes = include_bytes!("../test-data/image/avatar1000x1000.jpg");
tokio::fs::write(&file, bytes).await?;

View File

@@ -13,7 +13,7 @@ mod auto_mozilla;
mod auto_outlook;
pub(crate) mod server_params;
use anyhow::{bail, ensure, Context as _, Result};
use anyhow::{bail, ensure, format_err, Context as _, Result};
use auto_mozilla::moz_autoconfigure;
use auto_outlook::outlk_autodiscover;
use deltachat_contact_tools::EmailAddress;
@@ -80,10 +80,7 @@ impl Context {
let res = self
.inner_configure()
.race(cancel_channel.recv().map(|_| {
progress!(self, 0);
Ok(())
}))
.race(cancel_channel.recv().map(|_| Err(format_err!("Cancelled"))))
.await;
self.free_ongoing().await;
@@ -196,8 +193,8 @@ async fn get_configured_param(
param.smtp.password.clone()
};
let socks5_config = param.socks5_config.clone();
let socks5_enabled = socks5_config.is_some();
let proxy_config = param.proxy_config.clone();
let proxy_enabled = proxy_config.is_some();
let mut addr = param.addr.clone();
if param.oauth2 {
@@ -240,7 +237,7 @@ async fn get_configured_param(
"checking internal provider-info for offline autoconfig"
);
provider = provider::get_provider_info(ctx, &param_domain, socks5_enabled).await;
provider = provider::get_provider_info(ctx, &param_domain, proxy_enabled).await;
if let Some(provider) = provider {
if provider.server.is_empty() {
info!(ctx, "Offline autoconfig found, but no servers defined.");
@@ -356,7 +353,7 @@ async fn get_configured_param(
.collect(),
smtp_user: param.smtp.user.clone(),
smtp_password,
socks5_config: param.socks5_config.clone(),
proxy_config: param.proxy_config.clone(),
provider,
certificate_checks: match param.certificate_checks {
EnteredCertificateChecks::Automatic => ConfiguredCertificateChecks::Automatic,
@@ -388,7 +385,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Configure
let smtp_param = configured_param.smtp.clone();
let smtp_password = configured_param.smtp_password.clone();
let smtp_addr = configured_param.addr.clone();
let smtp_socks5 = configured_param.socks5_config.clone();
let proxy_config = configured_param.proxy_config.clone();
let smtp_config_task = task::spawn(async move {
let mut smtp = Smtp::new();
@@ -396,7 +393,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Configure
&context_smtp,
&smtp_param,
&smtp_password,
&smtp_socks5,
&proxy_config,
&smtp_addr,
strict_tls,
configured_param.oauth2,
@@ -414,7 +411,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Configure
let mut imap = Imap::new(
configured_param.imap.clone(),
configured_param.imap_password.clone(),
configured_param.socks5_config.clone(),
configured_param.proxy_config.clone(),
&configured_param.addr,
strict_tls,
configured_param.oauth2,

View File

@@ -515,8 +515,11 @@ impl Context {
Ok(val)
}
/// Does a background fetch
/// pauses the scheduler and does one imap fetch, then unpauses and returns
/// Does a single round of fetching from IMAP and returns.
///
/// Can be used even if I/O is currently stopped.
/// If I/O is currently stopped, starts a new IMAP connection
/// and fetches from Inbox and DeltaChat folders.
pub async fn background_fetch(&self) -> Result<()> {
if !(self.is_configured().await?) {
return Ok(());
@@ -524,35 +527,63 @@ impl Context {
let address = self.get_primary_self_addr().await?;
let time_start = tools::Time::now();
info!(self, "background_fetch started fetching {address}");
info!(self, "background_fetch started fetching {address}.");
let _pause_guard = self.scheduler.pause(self.clone()).await?;
if self.scheduler.is_running().await {
self.scheduler.maybe_network().await;
// connection
let mut connection = Imap::new_configured(self, channel::bounded(1).1).await?;
let mut session = connection.prepare(self).await?;
// Wait until fetching is finished.
// Ideally we could wait for connectivity change events,
// but sleep loop is good enough.
// fetch imap folders
for folder_meaning in [FolderMeaning::Inbox, FolderMeaning::Mvbox] {
let (_, watch_folder) = convert_folder_meaning(self, folder_meaning).await?;
connection
.fetch_move_delete(self, &mut session, &watch_folder, folder_meaning)
.await?;
}
// First 100 ms sleep in chunks of 10 ms.
for _ in 0..10 {
if self.all_work_done().await {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
// update quota (to send warning if full) - but only check it once in a while
if self
.quota_needs_update(DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT)
.await
{
if let Err(err) = self.update_recent_quota(&mut session).await {
warn!(self, "Failed to update quota: {err:#}.");
// If we are not finished in 100 ms, keep waking up every 100 ms.
while !self.all_work_done().await {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
} 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?;
// Start a new dedicated connection.
let mut connection = Imap::new_configured(self, channel::bounded(1).1).await?;
let mut session = connection.prepare(self).await?;
// Fetch IMAP folders.
// Inbox is fetched before Mvbox because fetching from Inbox
// may result in moving some messages to Mvbox.
for folder_meaning in [FolderMeaning::Inbox, FolderMeaning::Mvbox] {
if let Some((_folder_config, watch_folder)) =
convert_folder_meaning(self, folder_meaning).await?
{
connection
.fetch_move_delete(self, &mut session, &watch_folder, folder_meaning)
.await?;
}
}
// Update quota (to send warning if full) - but only check it once in a while.
if self
.quota_needs_update(DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT)
.await
{
if let Err(err) = self.update_recent_quota(&mut session).await {
warn!(self, "Failed to update quota: {err:#}.");
}
}
}
info!(
self,
"background_fetch done for {address} took {:?}",
"background_fetch done for {address} took {:?}.",
time_elapsed(&time_start),
);
@@ -726,7 +757,7 @@ impl Context {
let request_msgs = message::get_request_msg_cnt(self).await;
let contacts = Contact::get_real_cnt(self).await?;
let is_configured = self.get_config_int(Config::Configured).await?;
let socks5_enabled = self.get_config_int(Config::Socks5Enabled).await?;
let proxy_enabled = self.get_config_int(Config::ProxyEnabled).await?;
let dbversion = self
.sql
.get_raw_config_int("dbversion")
@@ -807,7 +838,7 @@ impl Context {
.unwrap_or_else(|| "<unset>".to_string()),
);
res.insert("is_configured", is_configured.to_string());
res.insert("socks5_enabled", socks5_enabled.to_string());
res.insert("proxy_enabled", proxy_enabled.to_string());
res.insert("entered_account_settings", l.to_string());
res.insert("used_account_settings", l2);
@@ -1267,6 +1298,12 @@ impl Context {
///
/// If `chat_id` is provided this searches only for messages in this chat, if `chat_id`
/// is `None` this searches messages from all chats.
///
/// NB: Wrt the search in long messages which are shown truncated with the "Show Full Message…"
/// button, we only look at the first several kilobytes. Let's not fix this -- one can send a
/// dictionary in the message that matches any reasonable search request, but the user won't see
/// the match because they should tap on "Show Full Message…" for that. Probably such messages
/// would only clutter search results.
pub async fn search_msgs(&self, chat_id: Option<ChatId>, query: &str) -> Result<Vec<MsgId>> {
let real_query = query.trim().to_lowercase();
if real_query.is_empty() {
@@ -1693,6 +1730,8 @@ mod tests {
"server_flags",
"skip_start_messages",
"smtp_certificate_checks",
"proxy_url", // May contain passwords, don't leak it to the logs.
"socks5_enabled", // SOCKS5 options are deprecated.
"socks5_host",
"socks5_port",
"socks5_user",

View File

@@ -98,19 +98,26 @@ impl MsgId {
Ok(())
}
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore.
pub(crate) async fn update_download_state(
self,
context: &Context,
download_state: DownloadState,
) -> Result<()> {
let msg = Message::load_from_db(context, self).await?;
context
if context
.sql
.execute(
"UPDATE msgs SET download_state=? WHERE id=?;",
(download_state, self),
)
.await?;
.await?
== 0
{
return Ok(());
}
let Some(msg) = Message::load_from_db_optional(context, self).await? else {
return Ok(());
};
context.emit_event(EventType::MsgsChanged {
chat_id: msg.chat_id,
msg_id: self,
@@ -135,7 +142,17 @@ pub(crate) async fn download_msg(
msg_id: MsgId,
session: &mut Session,
) -> Result<()> {
let msg = Message::load_from_db(context, msg_id).await?;
let Some(msg) = Message::load_from_db_optional(context, msg_id).await? else {
// If partially downloaded message was already deleted
// we do not know its Message-ID anymore
// so cannot download it.
//
// Probably the message expired due to `delete_device_after`
// setting or was otherwise removed from the device,
// so we don't want it to reappear anyway.
return Ok(());
};
let row = context
.sql
.query_row_optional(
@@ -312,11 +329,17 @@ mod tests {
DownloadState::InProgress,
DownloadState::Failure,
DownloadState::Done,
DownloadState::Done,
] {
msg_id.update_download_state(&t, *s).await?;
let msg = Message::load_from_db(&t, msg_id).await?;
assert_eq!(msg.download_state(), *s);
}
msg_id.delete_from_db(&t).await?;
// Nothing to do is ok.
msg_id
.update_download_state(&t, DownloadState::Done)
.await?;
Ok(())
}

View File

@@ -69,7 +69,7 @@ use std::num::ParseIntError;
use std::str::FromStr;
use std::time::{Duration, UNIX_EPOCH};
use anyhow::{ensure, Result};
use anyhow::{ensure, Context as _, Result};
use async_channel::Receiver;
use serde::{Deserialize, Serialize};
use tokio::time::timeout;
@@ -176,9 +176,13 @@ impl ChatId {
pub async fn get_ephemeral_timer(self, context: &Context) -> Result<Timer> {
let timer = context
.sql
.query_get_value("SELECT ephemeral_timer FROM chats WHERE id=?;", (self,))
.await?;
Ok(timer.unwrap_or_default())
.query_get_value(
"SELECT IFNULL(ephemeral_timer, 0) FROM chats WHERE id=?",
(self,),
)
.await?
.with_context(|| format!("Chat {self} not found"))?;
Ok(timer)
}
/// Set ephemeral timer value without sending a message.
@@ -509,7 +513,8 @@ async fn next_delete_device_after_timestamp(context: &Context) -> Result<Option<
FROM msgs
WHERE chat_id > ?
AND chat_id != ?
AND chat_id != ?;
AND chat_id != ?
HAVING count(*) > 0
"#,
(DC_CHAT_ID_TRASH, self_chat_id, device_chat_id),
)
@@ -533,7 +538,8 @@ async fn next_expiration_timestamp(context: &Context) -> Option<i64> {
SELECT min(ephemeral_timestamp)
FROM msgs
WHERE ephemeral_timestamp != 0
AND chat_id != ?;
AND chat_id != ?
HAVING count(*) > 0
"#,
(DC_CHAT_ID_TRASH,), // Trash contains already deleted messages, skip them
)
@@ -1410,4 +1416,14 @@ mod tests {
Ok(())
}
/// Tests that `.get_ephemeral_timer()` returns an error for invalid chat ID.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_ephemeral_timer_wrong_chat_id() -> Result<()> {
let context = TestContext::new().await;
let chat_id = ChatId::new(12345);
assert!(chat_id.get_ephemeral_timer(&context).await.is_err());
Ok(())
}
}

View File

@@ -32,17 +32,19 @@ use crate::contact::{Contact, ContactId, Modifier, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::log::LogExt;
use crate::login_param::{
prioritize_server_login_params, ConfiguredLoginParam, ConfiguredServerLoginParam,
};
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId, Viewtype};
use crate::mimeparser;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
use crate::oauth2::get_oauth2_access_token;
use crate::receive_imf::{
from_field_to_contact_id, get_prefetch_parent_message, receive_imf_inner, ReceivedMsg,
};
use crate::scheduler::connectivity::ConnectivityStore;
use crate::socks::Socks5Config;
use crate::sql;
use crate::stock_str;
use crate::tools::{self, create_id, duration_to_str};
@@ -54,7 +56,7 @@ pub mod scan_folders;
pub mod select_folder;
pub(crate) mod session;
use client::Client;
use client::{determine_capabilities, Client};
use mailparse::SingleInfo;
use session::Session;
@@ -80,8 +82,9 @@ pub(crate) struct Imap {
/// Password.
password: String,
/// SOCKS 5 configuration.
socks5_config: Option<Socks5Config>,
/// Proxy configuration.
proxy_config: Option<ProxyConfig>,
strict_tls: bool,
oauth2: bool,
@@ -237,7 +240,7 @@ impl Imap {
pub fn new(
lp: Vec<ConfiguredServerLoginParam>,
password: String,
socks5_config: Option<Socks5Config>,
proxy_config: Option<ProxyConfig>,
addr: &str,
strict_tls: bool,
oauth2: bool,
@@ -248,7 +251,7 @@ impl Imap {
addr: addr.to_string(),
lp,
password,
socks5_config,
proxy_config,
strict_tls,
oauth2,
login_failed_once: false,
@@ -271,7 +274,7 @@ impl Imap {
let imap = Self::new(
param.imap.clone(),
param.imap_password.clone(),
param.socks5_config.clone(),
param.proxy_config.clone(),
&param.addr,
param.strict_tls(),
param.oauth2,
@@ -336,7 +339,7 @@ impl Imap {
let connection_candidate = lp.connection.clone();
let client = match Client::connect(
context,
self.socks5_config.clone(),
self.proxy_config.clone(),
self.strict_tls,
connection_candidate,
)
@@ -374,7 +377,23 @@ impl Imap {
};
match login_res {
Ok(session) => {
Ok(mut session) => {
let capabilities = determine_capabilities(&mut session).await?;
let session = if capabilities.can_compress {
info!(context, "Enabling IMAP compression.");
let compressed_session = session
.compress(|s| {
let session_stream: Box<dyn SessionStream> = Box::new(s);
session_stream
})
.await
.context("Failed to enable IMAP compression")?;
Session::new(compressed_session, capabilities)
} else {
Session::new(session, capabilities)
};
// Store server ID in the context to display in account info.
let mut lock = context.server_id.write().await;
lock.clone_from(&session.capabilities.server_id);
@@ -1042,6 +1061,52 @@ impl Session {
Ok(())
}
/// Uploads sync messages from the `imap_send` table with `\Seen` flag set.
pub(crate) async fn send_sync_msgs(&mut self, context: &Context, folder: &str) -> Result<()> {
context.send_sync_msg().await?;
while let Some((id, mime, msg_id, attempts)) = context
.sql
.query_row_optional(
"SELECT id, mime, msg_id, attempts FROM imap_send ORDER BY id LIMIT 1",
(),
|row| {
let id: i64 = row.get(0)?;
let mime: String = row.get(1)?;
let msg_id: MsgId = row.get(2)?;
let attempts: i64 = row.get(3)?;
Ok((id, mime, msg_id, attempts))
},
)
.await
.context("Failed to SELECT from imap_send")?
{
let res = self
.append(folder, Some("(\\Seen)"), None, mime)
.await
.with_context(|| format!("IMAP APPEND to {folder} failed for {msg_id}"))
.log_err(context);
if res.is_ok() {
msg_id.set_delivered(context).await?;
}
const MAX_ATTEMPTS: i64 = 2;
if res.is_ok() || attempts >= MAX_ATTEMPTS - 1 {
context
.sql
.execute("DELETE FROM imap_send WHERE id=?", (id,))
.await
.context("Failed to delete from imap_send")?;
} else {
context
.sql
.execute("UPDATE imap_send SET attempts=attempts+1 WHERE id=?", (id,))
.await
.context("Failed to update imap_send.attempts")?;
res?;
}
}
Ok(())
}
/// Stores pending `\Seen` flags for messages in `imap_markseen` table.
pub(crate) async fn store_seen_flags_on_imap(&mut self, context: &Context) -> Result<()> {
let rows = context

View File

@@ -25,6 +25,10 @@ pub(crate) struct Capabilities {
/// <https://tools.ietf.org/html/rfc5464>
pub can_metadata: bool,
/// True if the server has COMPRESS=DEFLATE capability as defined in
/// <https://tools.ietf.org/html/rfc4978>
pub can_compress: bool,
/// True if the server supports XDELTAPUSH capability.
/// This capability means setting /private/devicetoken IMAP METADATA
/// on the INBOX results in new mail notifications

View File

@@ -4,20 +4,18 @@ use std::ops::{Deref, DerefMut};
use anyhow::{Context as _, Result};
use async_imap::Client as ImapClient;
use async_imap::Session as ImapSession;
use fast_socks5::client::Socks5Stream;
use tokio::io::BufWriter;
use super::capabilities::Capabilities;
use super::session::Session;
use crate::context::Context;
use crate::login_param::{ConnectionCandidate, ConnectionSecurity};
use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp};
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_tls;
use crate::net::{
connect_tcp_inner, connect_tls_inner, run_connection_attempts, update_connection_history,
};
use crate::socks::Socks5Config;
use crate::tools::time;
#[derive(Debug)]
@@ -52,7 +50,7 @@ fn alpn(port: u16) -> &'static [&'static str] {
/// Determine server capabilities.
///
/// If server supports ID capability, send our client ID.
async fn determine_capabilities(
pub(crate) async fn determine_capabilities(
session: &mut ImapSession<Box<dyn SessionStream>>,
) -> Result<Capabilities> {
let caps = session
@@ -70,6 +68,7 @@ async fn determine_capabilities(
can_check_quota: caps.has_str("QUOTA"),
can_condstore: caps.has_str("CONDSTORE"),
can_metadata: caps.has_str("METADATA"),
can_compress: caps.has_str("COMPRESS=DEFLATE"),
can_push: caps.has_str("XDELTAPUSH"),
is_chatmail: caps.has_str("XCHATMAIL"),
server_id,
@@ -84,28 +83,31 @@ impl Client {
}
}
pub(crate) async fn login(self, username: &str, password: &str) -> Result<Session> {
pub(crate) async fn login(
self,
username: &str,
password: &str,
) -> Result<ImapSession<Box<dyn SessionStream>>> {
let Client { inner, .. } = self;
let mut session = inner
let session = inner
.login(username, password)
.await
.map_err(|(err, _client)| err)?;
let capabilities = determine_capabilities(&mut session).await?;
Ok(Session::new(session, capabilities))
Ok(session)
}
pub(crate) async fn authenticate(
self,
auth_type: &str,
authenticator: impl async_imap::Authenticator,
) -> Result<Session> {
) -> Result<ImapSession<Box<dyn SessionStream>>> {
let Client { inner, .. } = self;
let mut session = inner
let session = inner
.authenticate(auth_type, authenticator)
.await
.map_err(|(err, _client)| err)?;
let capabilities = determine_capabilities(&mut session).await?;
Ok(Session::new(session, capabilities))
Ok(session)
}
async fn connection_attempt(
@@ -157,25 +159,25 @@ impl Client {
pub async fn connect(
context: &Context,
socks5_config: Option<Socks5Config>,
proxy_config: Option<ProxyConfig>,
strict_tls: bool,
candidate: ConnectionCandidate,
) -> Result<Self> {
let host = &candidate.host;
let port = candidate.port;
let security = candidate.security;
if let Some(socks5_config) = socks5_config {
if let Some(proxy_config) = proxy_config {
let client = match security {
ConnectionSecurity::Tls => {
Client::connect_secure_socks5(context, host, port, strict_tls, socks5_config)
Client::connect_secure_proxy(context, host, port, strict_tls, proxy_config)
.await?
}
ConnectionSecurity::Starttls => {
Client::connect_starttls_socks5(context, host, port, socks5_config, strict_tls)
Client::connect_starttls_proxy(context, host, port, proxy_config, strict_tls)
.await?
}
ConnectionSecurity::Plain => {
Client::connect_insecure_socks5(context, host, port, socks5_config).await?
Client::connect_insecure_proxy(context, host, port, proxy_config).await?
}
};
Ok(client)
@@ -249,17 +251,17 @@ impl Client {
Ok(client)
}
async fn connect_secure_socks5(
async fn connect_secure_proxy(
context: &Context,
domain: &str,
port: u16,
strict_tls: bool,
socks5_config: Socks5Config,
proxy_config: ProxyConfig,
) -> Result<Self> {
let socks5_stream = socks5_config
let proxy_stream = proxy_config
.connect(context, domain, port, strict_tls)
.await?;
let tls_stream = wrap_tls(strict_tls, domain, alpn(port), socks5_stream).await?;
let tls_stream = wrap_tls(strict_tls, domain, alpn(port), proxy_stream).await?;
let buffered_stream = BufWriter::new(tls_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let mut client = Client::new(session_stream);
@@ -270,14 +272,14 @@ impl Client {
Ok(client)
}
async fn connect_insecure_socks5(
async fn connect_insecure_proxy(
context: &Context,
domain: &str,
port: u16,
socks5_config: Socks5Config,
proxy_config: ProxyConfig,
) -> Result<Self> {
let socks5_stream = socks5_config.connect(context, domain, port, false).await?;
let buffered_stream = BufWriter::new(socks5_stream);
let proxy_stream = proxy_config.connect(context, domain, port, false).await?;
let buffered_stream = BufWriter::new(proxy_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let mut client = Client::new(session_stream);
let _greeting = client
@@ -287,20 +289,20 @@ impl Client {
Ok(client)
}
async fn connect_starttls_socks5(
async fn connect_starttls_proxy(
context: &Context,
hostname: &str,
port: u16,
socks5_config: Socks5Config,
proxy_config: ProxyConfig,
strict_tls: bool,
) -> Result<Self> {
let socks5_stream = socks5_config
let proxy_stream = proxy_config
.connect(context, hostname, port, strict_tls)
.await?;
// Run STARTTLS command and convert the client back into a stream.
let buffered_socks5_stream = BufWriter::new(socks5_stream);
let mut client = ImapClient::new(buffered_socks5_stream);
let buffered_proxy_stream = BufWriter::new(proxy_stream);
let mut client = ImapClient::new(buffered_proxy_stream);
let _greeting = client
.read_response()
.await
@@ -309,10 +311,10 @@ impl Client {
.run_command_and_check_ok("STARTTLS", None)
.await
.context("STARTTLS command failed")?;
let buffered_socks5_stream = client.into_inner();
let socks5_stream: Socks5Stream<_> = buffered_socks5_stream.into_inner();
let buffered_proxy_stream = client.into_inner();
let proxy_stream = buffered_proxy_stream.into_inner();
let tls_stream = wrap_tls(strict_tls, hostname, &[], socks5_stream)
let tls_stream = wrap_tls(strict_tls, hostname, &[], proxy_stream)
.await
.context("STARTTLS upgrade failed")?;
let buffered_stream = BufWriter::new(tls_stream);

View File

@@ -2,18 +2,21 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use ::pgp::types::KeyTrait;
use anyhow::{bail, ensure, format_err, Context as _, Result};
use deltachat_contact_tools::EmailAddress;
use futures::TryStreamExt;
use futures_lite::FutureExt;
use pin_project::pin_project;
use tokio::fs::{self, File};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_tar::Archive;
use crate::blob::BlobDirContents;
use crate::chat::{self, delete_and_reset_all_device_msgs};
use crate::config::Config;
use crate::context::Context;
use crate::e2ee;
use crate::events::EventType;
@@ -177,10 +180,7 @@ async fn set_self_key(context: &Context, armored: &str, set_default: bool) -> Re
info!(context, "No Autocrypt-Prefer-Encrypt header.");
};
let self_addr = context.get_primary_self_addr().await?;
let addr = EmailAddress::new(&self_addr)?;
let keypair = pgp::KeyPair {
addr,
public: public_key,
secret: private_key,
};
@@ -215,7 +215,7 @@ async fn imex_inner(
path.display()
);
ensure!(context.sql.is_open().await, "Database not opened.");
context.emit_event(EventType::ImexProgress(10));
context.emit_event(EventType::ImexProgress(1));
if what == ImexMode::ExportBackup || what == ImexMode::ExportSelfKeys {
// before we export anything, make sure the private key exists
@@ -297,12 +297,71 @@ pub(crate) async fn import_backup_stream<R: tokio::io::AsyncRead + Unpin>(
.0
}
/// Reader that emits progress events as bytes are read from it.
#[pin_project]
struct ProgressReader<R> {
/// Wrapped reader.
#[pin]
inner: R,
/// Number of bytes successfully read from the internal reader.
read: usize,
/// Total size of the backup .tar file expected to be read from the reader.
/// Used to calculate the progress.
file_size: usize,
/// Last progress emitted to avoid emitting the same progress value twice.
last_progress: usize,
/// Context for emitting progress events.
context: Context,
}
impl<R> ProgressReader<R> {
fn new(r: R, context: Context, file_size: u64) -> Self {
Self {
inner: r,
read: 0,
file_size: file_size as usize,
last_progress: 1,
context,
}
}
}
impl<R> AsyncRead for ProgressReader<R>
where
R: AsyncRead,
{
fn poll_read(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let this = self.project();
let before = buf.filled().len();
let res = this.inner.poll_read(cx, buf);
if let std::task::Poll::Ready(Ok(())) = res {
*this.read = this.read.saturating_add(buf.filled().len() - before);
let progress = std::cmp::min(1000 * *this.read / *this.file_size, 999);
if progress > *this.last_progress {
this.context.emit_event(EventType::ImexProgress(progress));
*this.last_progress = progress;
}
}
res
}
}
async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
context: &Context,
backup_file: R,
file_size: u64,
passphrase: String,
) -> (Result<()>,) {
let backup_file = ProgressReader::new(backup_file, context.clone(), file_size);
let mut archive = Archive::new(backup_file);
let mut entries = match archive.entries() {
@@ -310,29 +369,12 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
Err(e) => return (Err(e).context("Failed to get archive entries"),),
};
let mut blobs = Vec::new();
// We already emitted ImexProgress(10) above
let mut last_progress = 10;
const PROGRESS_MIGRATIONS: u128 = 999;
let mut total_size: u64 = 0;
let mut res: Result<()> = loop {
let mut f = match entries.try_next().await {
Ok(Some(f)) => f,
Ok(None) => break Ok(()),
Err(e) => break Err(e).context("Failed to get next entry"),
};
total_size += match f.header().entry_size() {
Ok(size) => size,
Err(e) => break Err(e).context("Failed to get entry size"),
};
let max = PROGRESS_MIGRATIONS - 1;
let progress = std::cmp::min(
max * u128::from(total_size) / std::cmp::max(u128::from(file_size), 1),
max,
);
if progress > last_progress {
context.emit_event(EventType::ImexProgress(progress as usize));
last_progress = progress;
}
let path = match f.path() {
Ok(path) => path.to_path_buf(),
@@ -373,13 +415,16 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
.await
.context("cannot import unpacked database");
}
if res.is_ok() {
res = adjust_delete_server_after(context).await;
}
fs::remove_file(unpacked_database)
.await
.context("cannot remove unpacked database")
.log_err(context)
.ok();
if res.is_ok() {
context.emit_event(EventType::ImexProgress(PROGRESS_MIGRATIONS as usize));
context.emit_event(EventType::ImexProgress(999));
res = context.sql.run_migrations(context).await;
}
if res.is_ok() {
@@ -452,7 +497,14 @@ async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Res
let file = File::create(&temp_path).await?;
let blobdir = BlobDirContents::new(context).await?;
export_backup_stream(context, &temp_db_path, blobdir, file)
let mut file_size = 0;
file_size += temp_db_path.metadata()?.len();
for blob in blobdir.iter() {
file_size += blob.to_abs_path().metadata()?.len()
}
export_backup_stream(context, &temp_db_path, blobdir, file, file_size)
.await
.context("Exporting backup to file failed")?;
fs::rename(temp_path, &dest_path).await?;
@@ -460,33 +512,99 @@ async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Res
Ok(())
}
/// Writer that emits progress events as bytes are written into it.
#[pin_project]
struct ProgressWriter<W> {
/// Wrapped writer.
#[pin]
inner: W,
/// Number of bytes successfully written into the internal writer.
written: usize,
/// Total size of the backup .tar file expected to be written into the writer.
/// Used to calculate the progress.
file_size: usize,
/// Last progress emitted to avoid emitting the same progress value twice.
last_progress: usize,
/// Context for emitting progress events.
context: Context,
}
impl<W> ProgressWriter<W> {
fn new(w: W, context: Context, file_size: u64) -> Self {
Self {
inner: w,
written: 0,
file_size: file_size as usize,
last_progress: 1,
context,
}
}
}
impl<W> AsyncWrite for ProgressWriter<W>
where
W: AsyncWrite,
{
fn poll_write(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<Result<usize, std::io::Error>> {
let this = self.project();
let res = this.inner.poll_write(cx, buf);
if let std::task::Poll::Ready(Ok(written)) = res {
*this.written = this.written.saturating_add(written);
let progress = std::cmp::min(1000 * *this.written / *this.file_size, 999);
if progress > *this.last_progress {
this.context.emit_event(EventType::ImexProgress(progress));
*this.last_progress = progress;
}
}
res
}
fn poll_flush(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), std::io::Error>> {
self.project().inner.poll_flush(cx)
}
fn poll_shutdown(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), std::io::Error>> {
self.project().inner.poll_shutdown(cx)
}
}
/// Exports the database and blobs into a stream.
pub(crate) async fn export_backup_stream<'a, W>(
context: &'a Context,
temp_db_path: &Path,
blobdir: BlobDirContents<'a>,
writer: W,
file_size: u64,
) -> Result<()>
where
W: tokio::io::AsyncWrite + tokio::io::AsyncWriteExt + Unpin + Send + 'static,
{
let writer = ProgressWriter::new(writer, context.clone(), file_size);
let mut builder = tokio_tar::Builder::new(writer);
builder
.append_path_with_name(temp_db_path, DBFILE_BACKUP_NAME)
.await?;
let mut last_progress = 10;
for (i, blob) in blobdir.iter().enumerate() {
for blob in blobdir.iter() {
let mut file = File::open(blob.to_abs_path()).await?;
let path_in_archive = PathBuf::from(BLOBS_BACKUP_NAME).join(blob.as_name());
builder.append_file(path_in_archive, &mut file).await?;
let progress = std::cmp::min(1000 * i / blobdir.len(), 999);
if progress > last_progress {
context.emit_event(EventType::ImexProgress(progress));
last_progress = progress;
}
}
builder.finish().await?;
@@ -677,6 +795,7 @@ async fn export_database(
.to_str()
.with_context(|| format!("path {} is not valid unicode", dest.display()))?;
adjust_delete_server_after(context).await?;
context
.sql
.set_raw_config_int("backup_time", timestamp)
@@ -706,6 +825,19 @@ async fn export_database(
.await
}
/// Sets `Config::DeleteServerAfter` to "never" if needed so that new messages are present on the
/// server after a backup restoration or available for all devices in multi-device case.
/// NB: Calling this after a backup import isn't reliable as we can crash in between, but this is a
/// problem only for old backups, new backups already have `DeleteServerAfter` set if necessary.
async fn adjust_delete_server_after(context: &Context) -> Result<()> {
if context.is_chatmail().await? && !context.config_exists(Config::DeleteServerAfter).await? {
context
.set_config(Config::DeleteServerAfter, Some("0"))
.await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
@@ -891,6 +1023,49 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_export_import_chatmail_backup() -> Result<()> {
let backup_dir = tempfile::tempdir().unwrap();
let context1 = &TestContext::new_alice().await;
// Check that the setting is displayed correctly.
assert_eq!(
context1.get_config(Config::DeleteServerAfter).await?,
Some("0".to_string())
);
context1.set_config_bool(Config::IsChatmail, true).await?;
assert_eq!(
context1.get_config(Config::DeleteServerAfter).await?,
Some("1".to_string())
);
assert_eq!(context1.get_config_delete_server_after().await?, Some(0));
imex(context1, ImexMode::ExportBackup, backup_dir.path(), None).await?;
let _event = context1
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
.await;
let context2 = &TestContext::new().await;
let backup = has_backup(context2, backup_dir.path()).await?;
imex(context2, ImexMode::ImportBackup, backup.as_ref(), None).await?;
let _event = context2
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
.await;
assert!(context2.is_configured().await?);
assert!(context2.is_chatmail().await?);
for ctx in [context1, context2] {
assert_eq!(
ctx.get_config(Config::DeleteServerAfter).await?,
Some("0".to_string())
);
assert_eq!(ctx.get_config_delete_server_after().await?, None);
}
Ok(())
}
/// This is a regression test for
/// https://github.com/deltachat/deltachat-android/issues/2263
/// where the config cache wasn't reset properly after a backup.

View File

@@ -31,7 +31,8 @@ use std::pin::Pin;
use std::sync::Arc;
use std::task::Poll;
use anyhow::{bail, Context as _, Result};
use anyhow::{bail, format_err, Context as _, Result};
use futures_lite::FutureExt;
use iroh_net::relay::RelayMode;
use iroh_net::Endpoint;
use tokio::fs;
@@ -97,7 +98,7 @@ impl BackupProvider {
let endpoint = Endpoint::builder()
.alpns(vec![BACKUP_ALPN.to_vec()])
.relay_mode(relay_mode)
.bind(0)
.bind()
.await?;
let node_addr = endpoint.node_addr().await?;
@@ -108,6 +109,7 @@ impl BackupProvider {
.get_blobdir()
.parent()
.context("Context dir not found")?;
let dbfile = context_dir.join(DBFILE_BACKUP_NAME);
if fs::metadata(&dbfile).await.is_ok() {
fs::remove_file(&dbfile).await?;
@@ -123,7 +125,6 @@ impl BackupProvider {
export_database(context, &dbfile, passphrase, time())
.await
.context("Database export failed")?;
context.emit_event(EventType::ImexProgress(300));
let drop_token = CancellationToken::new();
let handle = {
@@ -177,6 +178,7 @@ impl BackupProvider {
}
info!(context, "Received valid backup authentication token.");
context.emit_event(EventType::ImexProgress(1));
let blobdir = BlobDirContents::new(&context).await?;
@@ -188,7 +190,7 @@ impl BackupProvider {
send_stream.write_all(&file_size.to_be_bytes()).await?;
export_backup_stream(&context, &dbfile, blobdir, send_stream)
export_backup_stream(&context, &dbfile, blobdir, send_stream, file_size)
.await
.context("Failed to write backup into QUIC stream")?;
info!(context, "Finished writing backup into QUIC stream.");
@@ -220,12 +222,31 @@ impl BackupProvider {
conn = endpoint.accept() => {
if let Some(conn) = conn {
let conn = match conn.accept() {
Ok(conn) => conn,
Err(err) => {
warn!(context, "Failed to accept iroh connection: {err:#}.");
continue;
}
};
// Got a new in-progress connection.
let context = context.clone();
let auth_token = auth_token.clone();
let dbfile = dbfile.clone();
if let Err(err) = Self::handle_connection(context.clone(), conn, auth_token, dbfile).await {
if let Err(err) = Self::handle_connection(context.clone(), conn, auth_token, dbfile).race(
async {
cancel_token.recv().await.ok();
Err(format_err!("Backup transfer cancelled"))
}
).race(
async {
drop_token.cancelled().await;
Err(format_err!("Backup provider dropped"))
}
).await {
warn!(context, "Error while handling backup connection: {err:#}.");
context.emit_event(EventType::ImexProgress(0));
break;
} else {
info!(context, "Backup transfer finished successfully.");
break;
@@ -235,10 +256,12 @@ impl BackupProvider {
}
},
_ = cancel_token.recv() => {
info!(context, "Backup transfer cancelled by the user, stopping accept loop.");
context.emit_event(EventType::ImexProgress(0));
break;
}
_ = drop_token.cancelled() => {
info!(context, "Backup transfer cancelled by dropping the provider, stopping accept loop.");
context.emit_event(EventType::ImexProgress(0));
break;
}
@@ -274,7 +297,7 @@ pub async fn get_backup2(
) -> Result<()> {
let relay_mode = RelayMode::Disabled;
let endpoint = Endpoint::builder().relay_mode(relay_mode).bind(0).await?;
let endpoint = Endpoint::builder().relay_mode(relay_mode).bind().await?;
let conn = endpoint.connect(node_addr, BACKUP_ALPN).await?;
let (mut send_stream, mut recv_stream) = conn.open_bi().await?;
@@ -296,9 +319,13 @@ pub async fn get_backup2(
// Send an acknowledgement, but ignore the errors.
// We have imported backup successfully already.
send_stream.write_all(b".").await.ok();
send_stream.finish().await.ok();
send_stream.finish().ok();
info!(context, "Sent backup reception acknowledgment.");
// Wait for the peer to acknowledge reception of the acknowledgement
// before closing the connection.
_ = send_stream.stopped().await;
Ok(())
}
@@ -318,7 +345,20 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
Qr::Backup2 {
node_addr,
auth_token,
} => get_backup2(context, node_addr, auth_token).await?,
} => {
let cancel_token = context.alloc_ongoing().await?;
let res = get_backup2(context, node_addr, auth_token)
.race(async {
cancel_token.recv().await.ok();
Err(format_err!("Backup reception cancelled"))
})
.await;
if res.is_err() {
context.emit_event(EventType::ImexProgress(0));
}
context.free_ongoing().await;
res?;
}
_ => bail!("QR code for backup must be of type DCBACKUP2"),
}
Ok(())

View File

@@ -244,7 +244,7 @@ async fn generate_keypair(context: &Context) -> Result<KeyPair> {
let _guard = context.generating_key_mutex.lock().await;
// Check if the key appeared while we were waiting on the lock.
match load_keypair(context, &addr).await? {
match load_keypair(context).await? {
Some(key_pair) => Ok(key_pair),
None => {
let start = tools::Time::now();
@@ -266,10 +266,7 @@ async fn generate_keypair(context: &Context) -> Result<KeyPair> {
}
}
pub(crate) async fn load_keypair(
context: &Context,
addr: &EmailAddress,
) -> Result<Option<KeyPair>> {
pub(crate) async fn load_keypair(context: &Context) -> Result<Option<KeyPair>> {
let res = context
.sql
.query_row_optional(
@@ -287,7 +284,6 @@ pub(crate) async fn load_keypair(
Ok(if let Some((pub_bytes, sec_bytes)) = res {
Some(KeyPair {
addr: addr.clone(),
public: SignedPublicKey::from_slice(&pub_bytes)?,
secret: SignedSecretKey::from_slice(&sec_bytes)?,
})
@@ -337,17 +333,11 @@ pub(crate) async fn store_self_keypair(
KeyPairUse::ReadOnly => false,
};
// `addr` and `is_default` written for compatibility with older versions,
// until new cores are rolled out everywhere.
// otherwise "add second device" or "backup" may break.
// moreover, this allows downgrades to the previous version.
// writing of `addr` and `is_default` can be removed ~ 2024-08
let addr = keypair.addr.to_string();
transaction
.execute(
"INSERT OR REPLACE INTO keypairs (public_key, private_key, addr, is_default)
VALUES (?,?,?,?)",
(&public_key, &secret_key, addr, is_default),
"INSERT OR REPLACE INTO keypairs (public_key, private_key)
VALUES (?,?)",
(&public_key, &secret_key),
)
.context("Failed to insert keypair")?;
@@ -377,15 +367,10 @@ pub(crate) async fn store_self_keypair(
/// This API is used for testing purposes
/// to avoid generating the key in tests.
/// Use import/export APIs instead.
pub async fn preconfigure_keypair(context: &Context, addr: &str, secret_data: &str) -> Result<()> {
let addr = EmailAddress::new(addr)?;
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
let secret = SignedSecretKey::from_asc(secret_data)?.0;
let public = secret.split_public_key()?;
let keypair = KeyPair {
addr,
public,
secret,
};
let keypair = KeyPair { public, secret };
store_self_keypair(context, &keypair, KeyPairUse::Default).await?;
Ok(())
}

View File

@@ -84,7 +84,6 @@ mod scheduler;
pub mod securejoin;
mod simplify;
mod smtp;
mod socks;
pub mod stock_str;
mod sync;
mod timesmearing;

View File

@@ -11,8 +11,8 @@ use crate::configure::server_params::{expand_param_vector, ServerParams};
use crate::constants::{DC_LP_AUTH_FLAGS, DC_LP_AUTH_NORMAL, DC_LP_AUTH_OAUTH2};
use crate::context::Context;
use crate::net::load_connection_timestamp;
use crate::net::proxy::ProxyConfig;
use crate::provider::{Protocol, Provider, Socket, UsernamePattern};
use crate::socks::Socks5Config;
use crate::sql::Sql;
/// User-entered setting for certificate checks.
@@ -116,7 +116,8 @@ pub struct EnteredLoginParam {
/// invalid hostnames
pub certificate_checks: EnteredCertificateChecks,
pub socks5_config: Option<Socks5Config>,
/// Proxy configuration.
pub proxy_config: Option<ProxyConfig>,
pub oauth2: bool,
}
@@ -195,7 +196,7 @@ impl EnteredLoginParam {
.unwrap_or_default();
let oauth2 = matches!(server_flags & DC_LP_AUTH_FLAGS, DC_LP_AUTH_OAUTH2);
let socks5_config = Socks5Config::from_database(&context.sql).await?;
let proxy_config = ProxyConfig::load(context).await?;
Ok(EnteredLoginParam {
addr,
@@ -214,7 +215,7 @@ impl EnteredLoginParam {
password: send_pw,
},
certificate_checks,
socks5_config,
proxy_config,
oauth2,
})
}
@@ -380,7 +381,8 @@ pub struct ConfiguredLoginParam {
pub smtp_password: String,
pub socks5_config: Option<Socks5Config>,
/// Proxy configuration.
pub proxy_config: Option<ProxyConfig>,
pub provider: Option<&'static Provider>,
@@ -679,7 +681,7 @@ impl ConfiguredLoginParam {
}];
}
let socks5_config = Socks5Config::from_database(&context.sql).await?;
let proxy_config = ProxyConfig::load(context).await?;
Ok(Some(ConfiguredLoginParam {
addr,
@@ -691,7 +693,7 @@ impl ConfiguredLoginParam {
smtp_password: send_pw,
certificate_checks,
provider,
socks5_config,
proxy_config,
oauth2,
}))
}
@@ -778,7 +780,7 @@ impl ConfiguredLoginParam {
let provider_strict_tls = self.provider.map(|provider| provider.opt.strict_tls);
match self.certificate_checks {
ConfiguredCertificateChecks::OldAutomatic => {
provider_strict_tls.unwrap_or(self.socks5_config.is_some())
provider_strict_tls.unwrap_or(self.proxy_config.is_some())
}
ConfiguredCertificateChecks::Automatic => provider_strict_tls.unwrap_or(true),
ConfiguredCertificateChecks::Strict => true,
@@ -863,8 +865,8 @@ mod tests {
}],
smtp_user: "".to_string(),
smtp_password: "bar".to_string(),
// socks5_config is not saved by `save_to_database`, using default value
socks5_config: None,
// proxy_config is not saved by `save_to_database`, using default value
proxy_config: None,
provider: None,
certificate_checks: ConfiguredCertificateChecks::Strict,
oauth2: false,
@@ -967,7 +969,7 @@ mod tests {
],
smtp_user: "alice@posteo.de".to_string(),
smtp_password: "foobarbaz".to_string(),
socks5_config: None,
proxy_config: None,
provider: get_provider_by_id("posteo"),
certificate_checks: ConfiguredCertificateChecks::Strict,
oauth2: false,

View File

@@ -219,11 +219,13 @@ impl MsgId {
}
/// Returns information about hops of a message, used for message info
pub async fn hop_info(self, context: &Context) -> Result<Option<String>> {
context
pub async fn hop_info(self, context: &Context) -> Result<String> {
let hop_info = context
.sql
.query_get_value("SELECT hop_info FROM msgs WHERE id=?", (self,))
.await
.query_get_value("SELECT IFNULL(hop_info, '') FROM msgs WHERE id=?", (self,))
.await?
.with_context(|| format!("Message {self} not found"))?;
Ok(hop_info)
}
/// Returns detailed message information in a multi-line text form.
@@ -328,7 +330,12 @@ impl MsgId {
if let Some(path) = msg.get_file(context) {
let bytes = get_filebytes(context, &path).await?;
ret += &format!("\nFile: {}, {} bytes\n", path.display(), bytes);
ret += &format!(
"\nFile: {}, name: {}, {} bytes\n",
path.display(),
msg.get_filename().unwrap_or_default(),
bytes
);
}
if msg.viewtype != Viewtype::Text {
@@ -361,7 +368,11 @@ impl MsgId {
let hop_info = self.hop_info(context).await?;
ret += "\n\n";
ret += &hop_info.unwrap_or_else(|| "No Hop Info".to_owned());
if hop_info.is_empty() {
ret += "No Hop Info";
} else {
ret += &hop_info;
}
Ok(ret)
}
@@ -1815,8 +1826,8 @@ pub(crate) async fn update_msg_state(
context
.sql
.execute(
&format!("UPDATE msgs SET state=?1 {error_subst} WHERE id=?2 AND (?1!=?3 OR state<?3)"),
(state, msg_id, MessageState::OutDelivered),
&format!("UPDATE msgs SET state=? {error_subst} WHERE id=?"),
(state, msg_id),
)
.await?;
Ok(())
@@ -1993,7 +2004,9 @@ pub(crate) async fn rfc724_mid_exists_ex(
.query_row_optional(
&("SELECT id, timestamp_sent, MIN(".to_string()
+ expr
+ ") FROM msgs WHERE rfc724_mid=? ORDER BY timestamp_sent DESC"),
+ ") FROM msgs WHERE rfc724_mid=?
HAVING COUNT(*) > 0 -- Prevent MIN(expr) from returning NULL when there are no rows.
ORDER BY timestamp_sent DESC"),
(rfc724_mid,),
|row| {
let msg_id: MsgId = row.get(0)?;
@@ -2577,6 +2590,60 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_not_downloaded_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)
.await?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert!(!msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert_eq!(msg.state, MessageState::InFresh);
markseen_msgs(alice, vec![msg.id]).await?;
let msg = Message::load_from_db(alice, msg.id).await?;
assert_eq!(msg.state, MessageState::InSeen);
assert!(
!alice
.sql
.exists("SELECT COUNT(*) FROM smtp_mdns", ())
.await?
);
alice.set_config(Config::DownloadLimit, None).await?;
// Simulate that the message is even marked as `\Seen` on IMAP.
let rcvd_msg = receive_imf(alice, sent_msg.payload().as_bytes(), true)
.await
.unwrap()
.unwrap();
assert_eq!(rcvd_msg.chat_id, msg.chat_id);
let msg = Message::load_from_db(alice, *rcvd_msg.msg_ids.last().unwrap())
.await
.unwrap();
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
assert_eq!(msg.state, MessageState::InNoticed);
markseen_msgs(alice, vec![msg.id]).await?;
let msg = Message::load_from_db(alice, msg.id).await?;
assert_eq!(msg.state, MessageState::InSeen);
assert_eq!(
alice
.sql
.count("SELECT COUNT(*) FROM smtp_mdns", ())
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_state() -> Result<()> {
let alice = TestContext::new_alice().await;

View File

@@ -104,10 +104,8 @@ pub struct RenderedEmail {
pub is_gossiped: bool,
pub last_added_location_id: Option<u32>,
/// A comma-separated string of sync-IDs that are used by the rendered email
/// and must be deleted once the message is actually queued for sending
/// (deletion must be done by `delete_sync_ids()`).
/// If the rendered email is not queued for sending, the IDs must not be deleted.
/// A comma-separated string of sync-IDs that are used by the rendered email and must be deleted
/// from `multi_device_sync` once the message is actually queued for sending.
pub sync_ids_to_delete: Option<String>,
/// Message ID (Message in the sense of Email)
@@ -117,6 +115,13 @@ pub struct RenderedEmail {
pub subject: String,
}
fn new_address_with_name(name: &str, address: String) -> Address {
match name == address {
true => Address::new_mailbox(address),
false => Address::new_mailbox_with_name(name.to_string(), address),
}
}
impl MimeFactory {
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
let chat = Chat::load_from_db(context, msg.chat_id).await?;
@@ -143,7 +148,9 @@ impl MimeFactory {
let mut req_mdn = false;
if chat.is_self_talk() {
recipients.push((from_displayname.to_string(), from_addr.to_string()));
if msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage {
recipients.push((from_displayname.to_string(), from_addr.to_string()));
}
} else if chat.is_mailing_list() {
let list_post = chat
.param
@@ -194,7 +201,8 @@ impl MimeFactory {
let (in_reply_to, references) = context
.sql
.query_row(
"SELECT mime_in_reply_to, mime_references FROM msgs WHERE id=?",
"SELECT mime_in_reply_to, IFNULL(mime_references, '')
FROM msgs WHERE id=?",
(msg.id,),
|row| {
let in_reply_to: String = row.get(0)?;
@@ -472,10 +480,7 @@ impl MimeFactory {
pub async fn render(mut self, context: &Context) -> Result<RenderedEmail> {
let mut headers = Vec::<Header>::new();
let from = Address::new_mailbox_with_name(
self.from_displayname.to_string(),
self.from_addr.clone(),
);
let from = new_address_with_name(&self.from_displayname, self.from_addr.clone());
let undisclosed_recipients = match &self.loaded {
Loaded::Message { chat, .. } => chat.typ == Chattype::Broadcast,
@@ -510,10 +515,7 @@ impl MimeFactory {
if name.is_empty() {
to.push(Address::new_mailbox(addr.clone()));
} else {
to.push(Address::new_mailbox_with_name(
name.to_string(),
addr.clone(),
));
to.push(new_address_with_name(name, addr.clone()));
}
}
@@ -528,8 +530,7 @@ impl MimeFactory {
headers.push(from_header.clone());
if let Some(sender_displayname) = &self.sender_displayname {
let sender =
Address::new_mailbox_with_name(sender_displayname.clone(), self.from_addr.clone());
let sender = new_address_with_name(sender_displayname, self.from_addr.clone());
headers.push(Header::new_with_value("Sender".into(), vec![sender]).unwrap());
}
headers.push(Header::new_with_value("To".into(), to.clone()).unwrap());
@@ -1674,10 +1675,7 @@ mod tests {
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == ' '));
let s = format!(
"{}",
Address::new_mailbox_with_name(display_name.to_string(), addr.to_string())
);
let s = format!("{}", new_address_with_name(display_name, addr.to_string()));
println!("{s}");
@@ -1694,15 +1692,19 @@ mod tests {
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == ' '));
let s = format!(
"{}",
Address::new_mailbox_with_name(display_name.to_string(), addr.to_string())
);
let s = format!("{}", new_address_with_name(display_name, addr.to_string()));
// Addresses should not be unnecessarily be encoded, see <https://github.com/deltachat/deltachat-core-rust/issues/1575>:
assert_eq!(s, "a space <x@y.org>");
}
#[test]
fn test_render_email_address_duplicated_as_name() {
let addr = "x@y.org";
let s = format!("{}", new_address_with_name(addr, addr.to_string()));
assert_eq!(s, "<x@y.org>");
}
#[test]
fn test_render_rfc724_mid() {
assert_eq!(
@@ -2244,7 +2246,7 @@ mod tests {
if name.is_empty() {
Address::new_mailbox(addr.to_string())
} else {
Address::new_mailbox_with_name(name.to_string(), addr.to_string())
new_address_with_name(name, addr.to_string())
}
})
.collect();

View File

@@ -17,7 +17,7 @@ use crate::aheader::{Aheader, EncryptPreference};
use crate::blob::BlobObject;
use crate::chat::{add_info_msg, ChatId};
use crate::config::Config;
use crate::constants::{self, Chattype, DC_DESIRED_TEXT_LINES, DC_DESIRED_TEXT_LINE_LEN};
use crate::constants::{self, Chattype};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::decrypt::{
@@ -34,7 +34,7 @@ use crate::peerstate::Peerstate;
use crate::simplify::{simplify, SimplifiedText};
use crate::sync::SyncItems;
use crate::tools::{
create_smeared_timestamp, get_filemeta, parse_receive_headers, smeared_time, truncate_by_lines,
create_smeared_timestamp, get_filemeta, parse_receive_headers, smeared_time, truncate_msg_text,
validate_id,
};
use crate::{chatlist_events, location, stock_str, tools};
@@ -1179,22 +1179,11 @@ impl MimeMessage {
(simplified_txt, top_quote)
};
let is_bot = context.get_config_bool(Config::Bot).await?;
let simplified_txt = if is_bot {
simplified_txt
} else {
// Truncate text if it has too many lines
let (simplified_txt, was_truncated) = truncate_by_lines(
simplified_txt,
DC_DESIRED_TEXT_LINES,
DC_DESIRED_TEXT_LINE_LEN,
);
if was_truncated {
self.is_mime_modified = was_truncated;
}
simplified_txt
};
let (simplified_txt, was_truncated) =
truncate_msg_text(context, simplified_txt).await?;
if was_truncated {
self.is_mime_modified = was_truncated;
}
if !simplified_txt.is_empty() || simplified_quote.is_some() {
let mut part = Part {
@@ -3609,6 +3598,17 @@ On 2020-10-25, Bob wrote:
assert!(mimemsg.parts[0].msg.len() <= DC_DESIRED_TEXT_LEN + DC_ELLIPSIS.len());
}
{
let chat = t.get_self_chat().await;
t.send_text(chat.id, &long_txt).await;
let msg = t.get_last_msg_in(chat.id).await;
assert!(msg.has_html());
assert!(
msg.text.matches("just repeated").count() <= DC_DESIRED_TEXT_LEN / REPEAT_TXT.len()
);
assert!(msg.text.len() <= DC_DESIRED_TEXT_LEN + DC_ELLIPSIS.len());
}
t.set_config(Config::Bot, Some("1")).await?;
{

View File

@@ -17,6 +17,7 @@ use crate::tools::time;
pub(crate) mod dns;
pub(crate) mod http;
pub(crate) mod proxy;
pub(crate) mod session;
pub(crate) mod tls;

View File

@@ -8,9 +8,9 @@ use mime::Mime;
use serde::Serialize;
use crate::context::Context;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_tls;
use crate::socks::Socks5Config;
use crate::net::tls::wrap_rustls;
/// HTTP(S) GET response.
#[derive(Debug)]
@@ -43,7 +43,7 @@ where
{
let scheme = parsed_url.scheme_str().context("URL has no scheme")?;
let host = parsed_url.host().context("URL has no host")?;
let socks5_config_opt = Socks5Config::from_database(&context.sql).await?;
let proxy_config_opt = ProxyConfig::load(context).await?;
let stream: Box<dyn SessionStream> = match scheme {
"http" => {
@@ -54,11 +54,11 @@ where
// better resolve from scratch each time to prevent
// cache poisoning attacks from having lasting effects.
let load_cache = false;
if let Some(socks5_config) = socks5_config_opt {
let socks5_stream = socks5_config
if let Some(proxy_config) = proxy_config_opt {
let proxy_stream = proxy_config
.connect(context, host, port, load_cache)
.await?;
Box::new(socks5_stream)
Box::new(proxy_stream)
} else {
let tcp_stream = crate::net::connect_tcp(context, host, port, load_cache).await?;
Box::new(tcp_stream)
@@ -67,17 +67,16 @@ where
"https" => {
let port = parsed_url.port_u16().unwrap_or(443);
let load_cache = true;
let strict_tls = true;
if let Some(socks5_config) = socks5_config_opt {
let socks5_stream = socks5_config
if let Some(proxy_config) = proxy_config_opt {
let proxy_stream = proxy_config
.connect(context, host, port, load_cache)
.await?;
let tls_stream = wrap_tls(strict_tls, host, &[], socks5_stream).await?;
let tls_stream = wrap_rustls(host, &[], proxy_stream).await?;
Box::new(tls_stream)
} else {
let tcp_stream = crate::net::connect_tcp(context, host, port, load_cache).await?;
let tls_stream = wrap_tls(strict_tls, host, &[], tcp_stream).await?;
let tls_stream = wrap_rustls(host, &[], tcp_stream).await?;
Box::new(tls_stream)
}
}

655
src/net/proxy.rs Normal file
View File

@@ -0,0 +1,655 @@
//! # Proxy support.
//!
//! Delta Chat supports HTTP(S) CONNECT, SOCKS5 and Shadowsocks protocols.
use std::fmt;
use std::pin::Pin;
use anyhow::{bail, format_err, Context as _, Result};
use base64::Engine;
use bytes::{BufMut, BytesMut};
use fast_socks5::client::Socks5Stream;
use fast_socks5::util::target_addr::ToTargetAddr;
use fast_socks5::AuthenticationMethod;
use fast_socks5::Socks5Command;
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio_io_timeout::TimeoutStream;
use url::Url;
use crate::config::Config;
use crate::context::Context;
use crate::net::connect_tcp;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_rustls;
use crate::sql::Sql;
/// Default SOCKS5 port according to [RFC 1928](https://tools.ietf.org/html/rfc1928).
pub const DEFAULT_SOCKS_PORT: u16 = 1080;
#[derive(Debug, Clone)]
pub struct ShadowsocksConfig {
pub server_config: shadowsocks::config::ServerConfig,
}
impl PartialEq for ShadowsocksConfig {
fn eq(&self, other: &Self) -> bool {
self.server_config.to_url() == other.server_config.to_url()
}
}
impl Eq for ShadowsocksConfig {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpConfig {
/// HTTP proxy host.
pub host: String,
/// HTTP proxy port.
pub port: u16,
/// Username and password for basic authentication.
///
/// If set, `Proxy-Authorization` header is sent.
pub user_password: Option<(String, String)>,
}
impl HttpConfig {
fn from_url(url: Url) -> Result<Self> {
let host = url
.host_str()
.context("HTTP proxy URL has no host")?
.to_string();
let port = url
.port_or_known_default()
.context("HTTP(S) URLs are guaranteed to return Some port")?;
let user_password = if let Some(password) = url.password() {
let username = percent_encoding::percent_decode_str(url.username())
.decode_utf8()
.context("HTTP(S) proxy username is not a valid UTF-8")?
.to_string();
let password = percent_encoding::percent_decode_str(password)
.decode_utf8()
.context("HTTP(S) proxy password is not a valid UTF-8")?
.to_string();
Some((username, password))
} else {
None
};
let http_config = HttpConfig {
host,
port,
user_password,
};
Ok(http_config)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Socks5Config {
pub host: String,
pub port: u16,
pub user_password: Option<(String, String)>,
}
impl Socks5Config {
async fn connect(
&self,
context: &Context,
target_host: &str,
target_port: u16,
load_dns_cache: bool,
) -> Result<Socks5Stream<Pin<Box<TimeoutStream<TcpStream>>>>> {
let tcp_stream = connect_tcp(context, &self.host, self.port, load_dns_cache)
.await
.context("Failed to connect to SOCKS5 proxy")?;
let authentication_method = if let Some((username, password)) = self.user_password.as_ref()
{
Some(AuthenticationMethod::Password {
username: username.into(),
password: password.into(),
})
} else {
None
};
let mut socks_stream =
Socks5Stream::use_stream(tcp_stream, authentication_method, Default::default()).await?;
let target_addr = (target_host, target_port).to_target_addr()?;
socks_stream
.request(Socks5Command::TCPConnect, target_addr)
.await?;
Ok(socks_stream)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyConfig {
// HTTP proxy.
Http(HttpConfig),
// HTTPS proxy.
Https(HttpConfig),
// SOCKS5 proxy.
Socks5(Socks5Config),
// Shadowsocks proxy.
Shadowsocks(ShadowsocksConfig),
}
/// Constructs HTTP/1.1 `CONNECT` request for HTTP(S) proxy.
fn http_connect_request(host: &str, port: u16, auth: Option<(&str, &str)>) -> String {
// According to <https://datatracker.ietf.org/doc/html/rfc7230#section-5.4>
// clients MUST send `Host:` header in HTTP/1.1 requests,
// so repeat the host there.
let mut res = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
if let Some((username, password)) = auth {
res += "Proxy-Authorization: Basic ";
res += &base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"));
res += "\r\n";
}
res += "\r\n";
res
}
/// Sends HTTP/1.1 `CONNECT` request over given connection
/// to establish an HTTP tunnel.
///
/// Returns the same connection back so actual data can be tunneled over it.
async fn http_tunnel<T>(mut conn: T, host: &str, port: u16, auth: Option<(&str, &str)>) -> Result<T>
where
T: AsyncReadExt + AsyncWriteExt + Unpin,
{
// Send HTTP/1.1 CONNECT request.
let request = http_connect_request(host, port, auth);
conn.write_all(request.as_bytes()).await?;
let mut buffer = BytesMut::with_capacity(4096);
let res = loop {
if !buffer.has_remaining_mut() {
bail!("CONNECT response exceeded buffer size");
}
let n = conn.read_buf(&mut buffer).await?;
if n == 0 {
bail!("Unexpected end of CONNECT response");
}
let res = &buffer[..];
if res.ends_with(b"\r\n\r\n") {
// End of response is not reached, read more.
break res;
}
};
// Normally response looks like
// `HTTP/1.1 200 Connection established\r\n\r\n`.
if !res.starts_with(b"HTTP/") {
bail!("Unexpected HTTP CONNECT response: {res:?}");
}
// HTTP-version followed by space has fixed length
// according to RFC 7230:
// <https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2>
//
// Normally status line starts with `HTTP/1.1 `.
// We only care about 3-digit status code.
let status_code = res
.get(9..12)
.context("HTTP status line does not contain a status code")?;
// Interpert status code according to
// <https://datatracker.ietf.org/doc/html/rfc7231#section-6>.
if status_code == b"407" {
Err(format_err!("Proxy Authentication Required"))
} else if status_code.starts_with(b"2") {
// Success.
Ok(conn)
} else {
Err(format_err!(
"Failed to establish HTTP CONNECT tunnel: {res:?}"
))
}
}
impl ProxyConfig {
/// Creates a new proxy configuration by parsing given proxy URL.
fn from_url(url: &str) -> Result<Self> {
let url = Url::parse(url).context("Cannot parse proxy URL")?;
match url.scheme() {
"http" => {
let http_config = HttpConfig::from_url(url)?;
Ok(Self::Http(http_config))
}
"https" => {
let https_config = HttpConfig::from_url(url)?;
Ok(Self::Https(https_config))
}
"ss" => {
let server_config = shadowsocks::config::ServerConfig::from_url(url.as_str())?;
let shadowsocks_config = ShadowsocksConfig { server_config };
Ok(Self::Shadowsocks(shadowsocks_config))
}
// Because of `curl` convention,
// `socks5` URL scheme may be expected to resolve domain names locally
// with `socks5h` URL scheme meaning that hostnames are passed to the proxy.
// Resolving hostnames locally is not supported
// in Delta Chat when using a proxy
// to prevent DNS leaks.
// Because of this we do not distinguish
// between `socks5` and `socks5h`.
"socks5" => {
let host = url
.host_str()
.context("socks5 URL has no host")?
.to_string();
let port = url.port().unwrap_or(DEFAULT_SOCKS_PORT);
let user_password = if let Some(password) = url.password() {
let username = percent_encoding::percent_decode_str(url.username())
.decode_utf8()
.context("SOCKS5 username is not a valid UTF-8")?
.to_string();
let password = percent_encoding::percent_decode_str(password)
.decode_utf8()
.context("SOCKS5 password is not a valid UTF-8")?
.to_string();
Some((username, password))
} else {
None
};
let socks5_config = Socks5Config {
host,
port,
user_password,
};
Ok(Self::Socks5(socks5_config))
}
scheme => Err(format_err!("Unknown URL scheme {scheme:?}")),
}
}
/// Migrates legacy `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password`
/// config into `proxy_url` if `proxy_url` is unset or empty.
///
/// Unsets `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password` in any case.
async fn migrate_socks_config(sql: &Sql) -> Result<()> {
if sql.get_raw_config("proxy_url").await?.is_none() {
// Load legacy SOCKS5 settings.
if let Some(host) = sql
.get_raw_config("socks5_host")
.await?
.filter(|s| !s.is_empty())
{
let port: u16 = sql
.get_raw_config_int("socks5_port")
.await?
.unwrap_or(DEFAULT_SOCKS_PORT.into()) as u16;
let user = sql.get_raw_config("socks5_user").await?.unwrap_or_default();
let pass = sql
.get_raw_config("socks5_password")
.await?
.unwrap_or_default();
let mut proxy_url = "socks5://".to_string();
if !pass.is_empty() {
proxy_url += &percent_encode(user.as_bytes(), NON_ALPHANUMERIC).to_string();
proxy_url += ":";
proxy_url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
proxy_url += "@";
};
proxy_url += &host;
proxy_url += ":";
proxy_url += &port.to_string();
sql.set_raw_config("proxy_url", Some(&proxy_url)).await?;
} else {
sql.set_raw_config("proxy_url", Some("")).await?;
}
let socks5_enabled = sql.get_raw_config("socks5_enabled").await?;
sql.set_raw_config("proxy_enabled", socks5_enabled.as_deref())
.await?;
}
sql.set_raw_config("socks5_enabled", None).await?;
sql.set_raw_config("socks5_host", None).await?;
sql.set_raw_config("socks5_port", None).await?;
sql.set_raw_config("socks5_user", None).await?;
sql.set_raw_config("socks5_password", None).await?;
Ok(())
}
/// Reads proxy configuration from the database.
pub async fn load(context: &Context) -> Result<Option<Self>> {
Self::migrate_socks_config(&context.sql)
.await
.context("Failed to migrate legacy SOCKS config")?;
let enabled = context.get_config_bool(Config::ProxyEnabled).await?;
if !enabled {
return Ok(None);
}
let proxy_url = context
.get_config(Config::ProxyUrl)
.await?
.unwrap_or_default();
let proxy_url = proxy_url
.split_once('\n')
.map_or(proxy_url.clone(), |(first_url, _rest)| {
first_url.to_string()
});
let proxy_config = Self::from_url(&proxy_url).context("Failed to parse proxy URL")?;
Ok(Some(proxy_config))
}
/// If `load_dns_cache` is true, loads cached DNS resolution results.
/// Use this only if the connection is going to be protected with TLS checks.
pub async fn connect(
&self,
context: &Context,
target_host: &str,
target_port: u16,
load_dns_cache: bool,
) -> Result<Box<dyn SessionStream>> {
match self {
ProxyConfig::Http(http_config) => {
let load_cache = false;
let tcp_stream = crate::net::connect_tcp(
context,
&http_config.host,
http_config.port,
load_cache,
)
.await?;
let auth = if let Some((username, password)) = &http_config.user_password {
Some((username.as_str(), password.as_str()))
} else {
None
};
let tunnel_stream = http_tunnel(tcp_stream, target_host, target_port, auth).await?;
Ok(Box::new(tunnel_stream))
}
ProxyConfig::Https(https_config) => {
let load_cache = true;
let tcp_stream = crate::net::connect_tcp(
context,
&https_config.host,
https_config.port,
load_cache,
)
.await?;
let tls_stream = wrap_rustls(&https_config.host, &[], tcp_stream).await?;
let auth = if let Some((username, password)) = &https_config.user_password {
Some((username.as_str(), password.as_str()))
} else {
None
};
let tunnel_stream = http_tunnel(tls_stream, target_host, target_port, auth).await?;
Ok(Box::new(tunnel_stream))
}
ProxyConfig::Socks5(socks5_config) => {
let socks5_stream = socks5_config
.connect(context, target_host, target_port, load_dns_cache)
.await?;
Ok(Box::new(socks5_stream))
}
ProxyConfig::Shadowsocks(ShadowsocksConfig { server_config }) => {
let shadowsocks_context = shadowsocks::context::Context::new_shared(
shadowsocks::config::ServerType::Local,
);
let tcp_stream = {
let server_addr = server_config.addr();
let host = server_addr.host();
let port = server_addr.port();
connect_tcp(context, &host, port, load_dns_cache)
.await
.context("Failed to connect to Shadowsocks proxy")?
};
let shadowsocks_stream = shadowsocks::ProxyClientStream::from_stream(
shadowsocks_context,
tcp_stream,
server_config,
(target_host.to_string(), target_port),
);
Ok(Box::new(shadowsocks_stream))
}
}
}
}
impl fmt::Display for Socks5Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"host:{},port:{},user_password:{}",
self.host,
self.port,
if let Some(user_password) = self.user_password.clone() {
format!("user: {}, password: ***", user_password.0)
} else {
"user: None".to_string()
}
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_utils::TestContext;
#[test]
fn test_socks5_url() {
let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:9050").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Socks5(Socks5Config {
host: "127.0.0.1".to_string(),
port: 9050,
user_password: None
})
);
let proxy_config = ProxyConfig::from_url("socks5://foo:bar@127.0.0.1:9150").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Socks5(Socks5Config {
host: "127.0.0.1".to_string(),
port: 9150,
user_password: Some(("foo".to_string(), "bar".to_string()))
})
);
let proxy_config = ProxyConfig::from_url("socks5://%66oo:b%61r@127.0.0.1:9150").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Socks5(Socks5Config {
host: "127.0.0.1".to_string(),
port: 9150,
user_password: Some(("foo".to_string(), "bar".to_string()))
})
);
let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:80").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Socks5(Socks5Config {
host: "127.0.0.1".to_string(),
port: 80,
user_password: None
})
);
let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Socks5(Socks5Config {
host: "127.0.0.1".to_string(),
port: 1080,
user_password: None
})
);
let proxy_config = ProxyConfig::from_url("socks5://127.0.0.1:1080").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Socks5(Socks5Config {
host: "127.0.0.1".to_string(),
port: 1080,
user_password: None
})
);
}
#[test]
fn test_http_url() {
let proxy_config = ProxyConfig::from_url("http://127.0.0.1").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Http(HttpConfig {
host: "127.0.0.1".to_string(),
port: 80,
user_password: None
})
);
let proxy_config = ProxyConfig::from_url("http://127.0.0.1:80").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Http(HttpConfig {
host: "127.0.0.1".to_string(),
port: 80,
user_password: None
})
);
let proxy_config = ProxyConfig::from_url("http://127.0.0.1:443").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Http(HttpConfig {
host: "127.0.0.1".to_string(),
port: 443,
user_password: None
})
);
}
#[test]
fn test_https_url() {
let proxy_config = ProxyConfig::from_url("https://127.0.0.1").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Https(HttpConfig {
host: "127.0.0.1".to_string(),
port: 443,
user_password: None
})
);
let proxy_config = ProxyConfig::from_url("https://127.0.0.1:80").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Https(HttpConfig {
host: "127.0.0.1".to_string(),
port: 80,
user_password: None
})
);
let proxy_config = ProxyConfig::from_url("https://127.0.0.1:443").unwrap();
assert_eq!(
proxy_config,
ProxyConfig::Https(HttpConfig {
host: "127.0.0.1".to_string(),
port: 443,
user_password: None
})
);
}
#[test]
fn test_http_connect_request() {
assert_eq!(http_connect_request("example.org", 143, Some(("aladdin", "opensesame"))), "CONNECT example.org:143 HTTP/1.1\r\nHost: example.org:143\r\nProxy-Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l\r\n\r\n");
assert_eq!(
http_connect_request("example.net", 587, None),
"CONNECT example.net:587 HTTP/1.1\r\nHost: example.net:587\r\n\r\n"
);
}
#[test]
fn test_shadowsocks_url() {
// Example URL from <https://shadowsocks.org/doc/sip002.html>.
let proxy_config =
ProxyConfig::from_url("ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1")
.unwrap();
assert!(matches!(proxy_config, ProxyConfig::Shadowsocks(_)));
}
#[test]
fn test_invalid_proxy_url() {
assert!(ProxyConfig::from_url("foobar://127.0.0.1:9050").is_err());
assert!(ProxyConfig::from_url("abc").is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_socks5_migration() -> Result<()> {
let t = TestContext::new().await;
// Test that config is migrated on attempt to load even if disabled.
t.set_config(Config::Socks5Host, Some("127.0.0.1")).await?;
t.set_config(Config::Socks5Port, Some("9050")).await?;
let proxy_config = ProxyConfig::load(&t).await?;
// Even though proxy is not enabled, config should be migrated.
assert_eq!(proxy_config, None);
assert_eq!(
t.get_config(Config::ProxyUrl).await?.unwrap(),
"socks5://127.0.0.1:9050"
);
Ok(())
}
// Test SOCKS5 setting migration if proxy was never configured.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_socks5_migration_unconfigured() -> Result<()> {
let t = TestContext::new().await;
// Try to load config to trigger migration.
assert_eq!(ProxyConfig::load(&t).await?, None);
assert_eq!(t.get_config(Config::ProxyEnabled).await?, None);
assert_eq!(
t.get_config(Config::ProxyUrl).await?.unwrap(),
String::new()
);
Ok(())
}
// Test SOCKS5 setting migration if SOCKS5 host is empty.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_socks5_migration_empty() -> Result<()> {
let t = TestContext::new().await;
t.set_config(Config::Socks5Host, Some("")).await?;
// Try to load config to trigger migration.
assert_eq!(ProxyConfig::load(&t).await?, None);
assert_eq!(t.get_config(Config::ProxyEnabled).await?, None);
assert_eq!(
t.get_config(Config::ProxyUrl).await?.unwrap(),
String::new()
);
Ok(())
}
}

View File

@@ -1,4 +1,3 @@
use async_native_tls::TlsStream;
use fast_socks5::client::Socks5Stream;
use std::pin::Pin;
use std::time::Duration;
@@ -17,11 +16,16 @@ impl SessionStream for Box<dyn SessionStream> {
self.as_mut().set_read_timeout(timeout);
}
}
impl<T: SessionStream> SessionStream for TlsStream<T> {
impl<T: SessionStream> SessionStream for async_native_tls::TlsStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout);
}
}
impl<T: SessionStream> SessionStream for tokio_rustls::client::TlsStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().0.set_read_timeout(timeout);
}
}
impl<T: SessionStream> SessionStream for BufStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout);
@@ -44,6 +48,16 @@ impl<T: SessionStream> SessionStream for Socks5Stream<T> {
self.get_socket_mut().set_read_timeout(timeout)
}
}
impl<T: SessionStream> SessionStream for shadowsocks::ProxyClientStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout)
}
}
impl<T: SessionStream> SessionStream for async_imap::DeflateStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout)
}
}
/// Session stream with a read buffer.
pub(crate) trait SessionBufStream: SessionStream + AsyncBufRead {}

View File

@@ -1,4 +1,5 @@
//! TLS support.
use std::sync::Arc;
use anyhow::Result;
use async_native_tls::{Certificate, Protocol, TlsConnector, TlsStream};
@@ -14,62 +15,42 @@ static LETSENCRYPT_ROOT: Lazy<Certificate> = Lazy::new(|| {
.unwrap()
});
static IMAP_NAUTA_CU: Lazy<Certificate> = Lazy::new(|| {
Certificate::from_der(include_bytes!(
"../../assets/certificates/imap.nauta.cu.der"
))
.unwrap()
});
static SMTP_NAUTA_CU: Lazy<Certificate> = Lazy::new(|| {
Certificate::from_der(include_bytes!(
"../../assets/certificates/smtp.nauta.cu.der"
))
.unwrap()
});
fn build_tls(strict_tls: bool, hostname: &str, alpns: &[&str]) -> TlsConnector {
let tls_builder = TlsConnector::new()
.min_protocol_version(Some(Protocol::Tlsv12))
.request_alpns(alpns)
.add_root_certificate(LETSENCRYPT_ROOT.clone());
// Add self-signed certificates for known hostnames.
let tls_builder = match hostname {
"imap.nauta.cu" => tls_builder.add_root_certificate(IMAP_NAUTA_CU.clone()),
"smtp.nauta.cu" => tls_builder.add_root_certificate(SMTP_NAUTA_CU.clone()),
_ => tls_builder,
};
if strict_tls {
tls_builder
} else {
tls_builder
.danger_accept_invalid_hostnames(true)
.danger_accept_invalid_certs(true)
}
}
pub async fn wrap_tls<T: AsyncRead + AsyncWrite + Unpin>(
strict_tls: bool,
hostname: &str,
alpn: &[&str],
stream: T,
) -> Result<TlsStream<T>> {
let tls = build_tls(strict_tls, hostname, alpn);
let tls_builder = TlsConnector::new()
.min_protocol_version(Some(Protocol::Tlsv12))
.request_alpns(alpn)
.add_root_certificate(LETSENCRYPT_ROOT.clone());
let tls = if strict_tls {
tls_builder
} else {
tls_builder
.danger_accept_invalid_hostnames(true)
.danger_accept_invalid_certs(true)
};
let tls_stream = tls.connect(hostname, stream).await?;
Ok(tls_stream)
}
#[cfg(test)]
mod tests {
use super::*;
pub async fn wrap_rustls<T: AsyncRead + AsyncWrite + Unpin>(
hostname: &str,
alpn: &[&str],
stream: T,
) -> Result<tokio_rustls::client::TlsStream<T>> {
let mut root_cert_store = rustls::RootCertStore::empty();
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
#[test]
fn test_build_tls() {
// we are using some additional root certificates.
// make sure, they do not break construction of TlsConnector
let _ = build_tls(true, "example.org", &[]);
let _ = build_tls(false, "example.org", &[]);
}
let mut config = rustls::ClientConfig::builder()
.with_root_certificates(root_cert_store)
.with_no_client_auth();
config.alpn_protocols = alpn.iter().map(|s| s.as_bytes().to_vec()).collect();
let tls = tokio_rustls::TlsConnector::from(Arc::new(config));
let name = rustls_pki_types::ServerName::try_from(hostname)?.to_owned();
let tls_stream = tls.connect(name, stream).await?;
Ok(tls_stream)
}

View File

@@ -6,7 +6,6 @@ use anyhow::{Context as _, Result};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::Deserialize;
use crate::config::Config;
use crate::context::Context;
use crate::net::http::post_form;
use crate::net::read_url_blob;
@@ -62,8 +61,7 @@ pub async fn get_oauth2_url(
addr: &str,
redirect_uri: &str,
) -> Result<Option<String>> {
let socks5_enabled = context.get_config_bool(Config::Socks5Enabled).await?;
if let Some(oauth2) = Oauth2::from_address(context, addr, socks5_enabled).await {
if let Some(oauth2) = Oauth2::from_address(context, addr).await {
context
.sql
.set_raw_config("oauth2_pending_redirect_uri", Some(redirect_uri))
@@ -83,8 +81,7 @@ pub(crate) async fn get_oauth2_access_token(
code: &str,
regenerate: bool,
) -> Result<Option<String>> {
let socks5_enabled = context.get_config_bool(Config::Socks5Enabled).await?;
if let Some(oauth2) = Oauth2::from_address(context, addr, socks5_enabled).await {
if let Some(oauth2) = Oauth2::from_address(context, addr).await {
let lock = context.oauth2_mutex.lock().await;
// read generated token
@@ -232,8 +229,7 @@ pub(crate) async fn get_oauth2_addr(
addr: &str,
code: &str,
) -> Result<Option<String>> {
let socks5_enabled = context.get_config_bool(Config::Socks5Enabled).await?;
let oauth2 = match Oauth2::from_address(context, addr, socks5_enabled).await {
let oauth2 = match Oauth2::from_address(context, addr).await {
Some(o) => o,
None => return Ok(None),
};
@@ -268,8 +264,9 @@ pub(crate) async fn get_oauth2_addr(
}
impl Oauth2 {
async fn from_address(context: &Context, addr: &str, skip_mx: bool) -> Option<Self> {
async fn from_address(context: &Context, addr: &str) -> Option<Self> {
let addr_normalized = normalize_addr(addr);
let skip_mx = true;
if let Some(domain) = addr_normalized
.find('@')
.map(|index| addr_normalized.split_at(index + 1).1)
@@ -367,38 +364,20 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_oauth_from_address() {
let t = TestContext::new().await;
assert_eq!(
Oauth2::from_address(&t, "hello@gmail.com", false).await,
Some(OAUTH2_GMAIL)
);
assert_eq!(
Oauth2::from_address(&t, "hello@googlemail.com", false).await,
Some(OAUTH2_GMAIL)
);
assert_eq!(
Oauth2::from_address(&t, "hello@yandex.com", false).await,
Some(OAUTH2_YANDEX)
);
assert_eq!(
Oauth2::from_address(&t, "hello@yandex.ru", false).await,
Some(OAUTH2_YANDEX)
);
assert_eq!(Oauth2::from_address(&t, "hello@web.de", false).await, None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_oauth_from_mx() {
// youtube staff seems to use "google workspace with oauth2", figures this out by MX lookup
let t = TestContext::new().await;
// Delta Chat does not have working Gmail client ID anymore.
assert_eq!(Oauth2::from_address(&t, "hello@gmail.com").await, None);
assert_eq!(Oauth2::from_address(&t, "hello@googlemail.com").await, None);
assert_eq!(
Oauth2::from_address(&t, "hello@youtube.com", false).await,
Some(OAUTH2_GMAIL)
Oauth2::from_address(&t, "hello@yandex.com").await,
Some(OAUTH2_YANDEX)
);
// without MX lookup, we would not know as youtube.com is not in our provider-db
assert_eq!(
Oauth2::from_address(&t, "hello@youtube.com", true).await,
None
Oauth2::from_address(&t, "hello@yandex.ru").await,
Some(OAUTH2_YANDEX)
);
assert_eq!(Oauth2::from_address(&t, "hello@web.de").await, None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -414,11 +393,11 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_oauth2_url() {
let ctx = TestContext::new().await;
let addr = "dignifiedquire@gmail.com";
let addr = "example@yandex.com";
let redirect_uri = "chat.delta:/com.b44t.messenger";
let res = get_oauth2_url(&ctx.ctx, addr, redirect_uri).await.unwrap();
assert_eq!(res, Some("https://accounts.google.com/o/oauth2/auth?client_id=959970109878%2D4mvtgf6feshskf7695nfln6002mom908%2Eapps%2Egoogleusercontent%2Ecom&redirect_uri=chat%2Edelta%3A%2Fcom%2Eb44t%2Emessenger&response_type=code&scope=https%3A%2F%2Fmail.google.com%2F%20email&access_type=offline".into()));
assert_eq!(res, Some("https://oauth.yandex.com/authorize?client_id=c4d0b6735fc8420a816d7e1303469341&response_type=code&scope=mail%3Aimap_full%20mail%3Asmtp&force_confirm=true".into()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -253,7 +253,7 @@ impl Context {
.secret_key(secret_key)
.alpns(vec![GOSSIP_ALPN.to_vec()])
.relay_mode(relay_mode)
.bind(0)
.bind()
.await?;
// create gossip
@@ -265,7 +265,6 @@ impl Context {
// Shuts down on deltachat shutdown
tokio::spawn(endpoint_loop(context, endpoint.clone(), gossip.clone()));
tokio::spawn(gossip_direct_address_loop(endpoint.clone(), gossip.clone()));
Ok(Iroh {
endpoint,
@@ -285,15 +284,6 @@ impl Context {
}
}
/// Loop to update direct addresses of the gossip.
async fn gossip_direct_address_loop(endpoint: Endpoint, gossip: Gossip) -> Result<()> {
let mut stream = endpoint.direct_addresses();
while let Some(addrs) = stream.next().await {
gossip.update_direct_addresses(&addrs)?;
}
Ok(())
}
/// Cache a peers [NodeId] for one topic.
pub(crate) async fn iroh_add_peer_for_topic(
ctx: &Context,
@@ -442,6 +432,13 @@ pub(crate) async fn create_iroh_header(
async fn endpoint_loop(context: Context, endpoint: Endpoint, gossip: Gossip) {
while let Some(conn) = endpoint.accept().await {
let conn = match conn.accept() {
Ok(conn) => conn,
Err(err) => {
warn!(context, "Failed to accept iroh connection: {err:#}.");
continue;
}
};
info!(context, "IROH_REALTIME: accepting iroh connection");
let gossip = gossip.clone();
let context = context.clone();

View File

@@ -14,9 +14,7 @@ use pgp::composed::{
use pgp::crypto::ecc_curve::ECCCurve;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::types::{
CompressionAlgorithm, KeyTrait, Mpi, PublicKeyTrait, SecretKeyTrait, StringToKey,
};
use pgp::types::{CompressionAlgorithm, KeyTrait, Mpi, PublicKeyTrait, StringToKey};
use rand::{thread_rng, CryptoRng, Rng};
use tokio::runtime::Handle;
@@ -135,9 +133,6 @@ pub fn split_armored_data(buf: &[u8]) -> Result<(BlockType, BTreeMap<String, Str
/// keys together as they are one unit.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct KeyPair {
/// Email address.
pub addr: EmailAddress,
/// Public key.
pub public: SignedPublicKey,
@@ -145,6 +140,18 @@ pub struct KeyPair {
pub secret: SignedSecretKey,
}
impl KeyPair {
/// Creates new keypair from a secret key.
///
/// Public key is split off the secret key.
pub fn new(secret: SignedSecretKey) -> Result<Self> {
use crate::key::DcSecretKey;
let public = secret.split_public_key()?;
Ok(Self { public, secret })
}
}
/// Create a new key pair.
///
/// Both secret and public key consist of signing primary key and encryption subkey
@@ -201,19 +208,12 @@ pub(crate) fn create_keypair(addr: EmailAddress, keygen_type: KeyGenType) -> Res
.verify()
.context("invalid secret key generated")?;
let public_key = secret_key
.public_key()
.sign(&secret_key, || "".into())
.context("failed to sign public key")?;
public_key
let key_pair = KeyPair::new(secret_key)?;
key_pair
.public
.verify()
.context("invalid public key generated")?;
Ok(KeyPair {
addr,
public: public_key,
secret: secret_key,
})
Ok(key_pair)
}
/// Select public key or subkey to use for encryption.

View File

@@ -509,6 +509,8 @@ static P_FREENET_DE: Provider = Provider {
overview_page: "https://providers.delta.chat/freenet-de",
server: &[
Server { protocol: Imap, socket: Ssl, hostname: "mx.freenet.de", port: 993, username_pattern: Email },
Server { protocol: Imap, socket: Starttls, hostname: "mx.freenet.de", port: 143, username_pattern: Email },
Server { protocol: Smtp, socket: Ssl, hostname: "mx.freenet.de", port: 465, username_pattern: Email },
Server { protocol: Smtp, socket: Starttls, hostname: "mx.freenet.de", port: 587, username_pattern: Email },
],
opt: ProviderOptions::new(),
@@ -532,7 +534,7 @@ static P_GMAIL: Provider = Provider {
..ProviderOptions::new()
},
config_defaults: None,
oauth2_authorizer: Some(Oauth2Authorizer::Gmail),
oauth2_authorizer: None,
};
// gmx.net.md: gmx.net, gmx.de, gmx.at, gmx.ch, gmx.org, gmx.eu, gmx.info, gmx.biz, gmx.com
@@ -938,6 +940,41 @@ static P_MEHL_STORE: Provider = Provider {
oauth2_authorizer: None,
};
// migadu.md: migadu.com
static P_MIGADU: Provider = Provider {
id: "migadu",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/migadu",
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "imap.migadu.com",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "smtp.migadu.com",
port: 465,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Starttls,
hostname: "smtp.migadu.com",
port: 587,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
};
// nauta.cu.md: nauta.cu
static P_NAUTA_CU: Provider = Provider {
id: "nauta.cu",
@@ -1158,7 +1195,7 @@ static P_OUVATON_COOP: Provider = Provider {
oauth2_authorizer: None,
};
// posteo.md: posteo.de, posteo.af, posteo.at, posteo.be, posteo.ca, posteo.ch, posteo.cl, posteo.co, posteo.co.uk, posteo.com.br, posteo.cr, posteo.cz, posteo.dk, posteo.ee, posteo.es, posteo.eu, posteo.fi, posteo.gl, posteo.gr, posteo.hn, posteo.hr, posteo.hu, posteo.ie, posteo.in, posteo.is, posteo.it, posteo.jp, posteo.la, posteo.li, posteo.lt, posteo.lu, posteo.me, posteo.mx, posteo.my, posteo.net, posteo.nl, posteo.no, posteo.nz, posteo.org, posteo.pe, posteo.pl, posteo.pm, posteo.pt, posteo.ro, posteo.ru, posteo.se, posteo.sg, posteo.si, posteo.tn, posteo.uk, posteo.us
// posteo.md: posteo.de, posteo.af, posteo.at, posteo.be, posteo.ca, posteo.ch, posteo.cl, posteo.co, posteo.co.uk, posteo.com, posteo.com.br, posteo.cr, posteo.cz, posteo.dk, posteo.ee, posteo.es, posteo.eu, posteo.fi, posteo.gl, posteo.gr, posteo.hn, posteo.hr, posteo.hu, posteo.ie, posteo.in, posteo.is, posteo.it, posteo.jp, posteo.la, posteo.li, posteo.lt, posteo.lu, posteo.me, posteo.mx, posteo.my, posteo.net, posteo.nl, posteo.no, posteo.nz, posteo.org, posteo.pe, posteo.pl, posteo.pm, posteo.pt, posteo.ro, posteo.ru, posteo.se, posteo.sg, posteo.si, posteo.tn, posteo.uk, posteo.us
static P_POSTEO: Provider = Provider {
id: "posteo",
status: Status::Ok,
@@ -1532,11 +1569,26 @@ static P_TUTANOTA: Provider = Provider {
// ukr.net.md: ukr.net
static P_UKR_NET: Provider = Provider {
id: "ukr.net",
status: Status::Ok,
before_login_hint: "",
status: Status::Preparation,
before_login_hint: "You must allow IMAP access to your account before you can login.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/ukr-net",
server: &[],
server: &[
Server {
protocol: Imap,
socket: Ssl,
hostname: "imap.ukr.net",
port: 993,
username_pattern: Email,
},
Server {
protocol: Smtp,
socket: Ssl,
hostname: "smtp.ukr.net",
port: 465,
username_pattern: Email,
},
],
opt: ProviderOptions::new(),
config_defaults: None,
oauth2_authorizer: None,
@@ -1818,7 +1870,7 @@ static P_ZOHO: Provider = Provider {
oauth2_authorizer: None,
};
pub(crate) static PROVIDER_DATA: [(&str, &Provider); 531] = [
pub(crate) static PROVIDER_DATA: [(&str, &Provider); 533] = [
("163.com", &P_163),
("aktivix.org", &P_AKTIVIX_ORG),
("aliyun.com", &P_ALIYUN),
@@ -2191,6 +2243,7 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 531] = [
("ente.quest", &P_MEHL_STORE),
("ente.cfd", &P_MEHL_STORE),
("nein.jetzt", &P_MEHL_STORE),
("migadu.com", &P_MIGADU),
("nauta.cu", &P_NAUTA_CU),
("naver.com", &P_NAVER),
("nine.testrun.org", &P_NINE_TESTRUN_ORG),
@@ -2211,6 +2264,7 @@ pub(crate) static PROVIDER_DATA: [(&str, &Provider); 531] = [
("posteo.cl", &P_POSTEO),
("posteo.co", &P_POSTEO),
("posteo.co.uk", &P_POSTEO),
("posteo.com", &P_POSTEO),
("posteo.com.br", &P_POSTEO),
("posteo.cr", &P_POSTEO),
("posteo.cz", &P_POSTEO),
@@ -2393,6 +2447,7 @@ pub(crate) static PROVIDER_IDS: Lazy<HashMap<&'static str, &'static Provider>> =
("mailo.com", &P_MAILO_COM),
("mehl.cloud", &P_MEHL_CLOUD),
("mehl.store", &P_MEHL_STORE),
("migadu", &P_MIGADU),
("nauta.cu", &P_NAUTA_CU),
("naver", &P_NAVER),
("nine.testrun.org", &P_NINE_TESTRUN_ORG),
@@ -2431,4 +2486,4 @@ pub(crate) static PROVIDER_IDS: Lazy<HashMap<&'static str, &'static Provider>> =
});
pub static _PROVIDER_UPDATED: Lazy<chrono::NaiveDate> =
Lazy::new(|| chrono::NaiveDate::from_ymd_opt(2024, 8, 23).unwrap());
Lazy::new(|| chrono::NaiveDate::from_ymd_opt(2024, 9, 13).unwrap());

349
src/qr.rs
View File

@@ -7,11 +7,11 @@ use anyhow::{anyhow, bail, ensure, Context as _, Result};
pub use dclogin_scheme::LoginOptions;
use deltachat_contact_tools::{addr_normalize, may_be_valid_addr, ContactAddress};
use once_cell::sync::Lazy;
use percent_encoding::percent_decode_str;
use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC};
use serde::Deserialize;
use self::dclogin_scheme::configure_from_login_qr;
use crate::chat::{get_chat_id_by_grpid, ChatIdBlocked};
use crate::chat::ChatIdBlocked;
use crate::config::Config;
use crate::constants::Blocked;
use crate::contact::{Contact, ContactId, Origin};
@@ -20,6 +20,7 @@ use crate::events::EventType;
use crate::key::Fingerprint;
use crate::message::Message;
use crate::net::http::post_empty;
use crate::net::proxy::DEFAULT_SOCKS_PORT;
use crate::peerstate::Peerstate;
use crate::token;
use crate::tools::validate_id;
@@ -35,8 +36,8 @@ const MAILTO_SCHEME: &str = "mailto:";
const MATMSG_SCHEME: &str = "MATMSG:";
const VCARD_SCHEME: &str = "BEGIN:VCARD";
const SMTP_SCHEME: &str = "SMTP:";
const HTTP_SCHEME: &str = "http://";
const HTTPS_SCHEME: &str = "https://";
const SHADOWSOCKS_SCHEME: &str = "ss://";
/// Backup transfer based on iroh-net.
pub(crate) const DCBACKUP2_SCHEME: &str = "DCBACKUP2:";
@@ -126,19 +127,26 @@ pub enum Qr {
instance_pattern: String,
},
/// Ask the user if they want to add or use the given SOCKS5 proxy
Socks5Proxy {
/// SOCKS5 server
/// Ask the user if they want to use the given proxy.
///
/// Note that HTTP(S) URLs without a path
/// and query parameters are treated as HTTP(S) proxy URL.
/// UI may want to still offer to open the URL
/// in the browser if QR code contents
/// starts with `http://` or `https://`
/// and the QR code was not scanned from
/// the proxy configuration screen.
Proxy {
/// Proxy URL.
///
/// This is the URL that is going to be added.
url: String,
/// Host extracted from the URL to display in the UI.
host: String,
/// SOCKS5 port
/// Port extracted from the URL to display in the UI.
port: u16,
/// SOCKS5 user
user: Option<String>,
/// SOCKS5 password
pass: Option<String>,
},
/// Contact address is scanned.
@@ -278,6 +286,8 @@ pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
decode_webrtc_instance(context, qr)?
} else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
decode_tg_socks_proxy(context, qr)?
} else if qr.starts_with(SHADOWSOCKS_SCHEME) {
decode_shadowsocks_proxy(qr)?
} else if starts_with_ignore_case(qr, DCBACKUP2_SCHEME) {
decode_backup2(qr)?
} else if qr.starts_with(MAILTO_SCHEME) {
@@ -288,9 +298,44 @@ pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
decode_matmsg(context, qr).await?
} else if qr.starts_with(VCARD_SCHEME) {
decode_vcard(context, qr).await?
} else if qr.starts_with(HTTP_SCHEME) || qr.starts_with(HTTPS_SCHEME) {
Qr::Url {
url: qr.to_string(),
} else if let Ok(url) = url::Url::parse(qr) {
match url.scheme() {
"socks5" => Qr::Proxy {
url: qr.to_string(),
host: url.host_str().context("URL has no host")?.to_string(),
port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
},
"http" | "https" => {
// Parsing with a non-standard scheme
// is a hack to work around the `url` crate bug
// <https://github.com/servo/rust-url/issues/957>.
let url = if let Some(rest) = qr.strip_prefix("http://") {
url::Url::parse(&format!("foobarbaz://{rest}"))?
} else if let Some(rest) = qr.strip_prefix("https://") {
url::Url::parse(&format!("foobarbaz://{rest}"))?
} else {
// Should not happen.
url
};
if url.port().is_none() | (url.path() != "") | url.query().is_some() {
// URL without a port, with a path or query cannot be a proxy URL.
Qr::Url {
url: qr.to_string(),
}
} else {
Qr::Proxy {
url: qr.to_string(),
host: url.host_str().context("URL has no host")?.to_string(),
port: url
.port_or_known_default()
.context("HTTP(S) URLs are guaranteed to return Some port")?,
}
}
}
_ => Qr::Url {
url: qr.to_string(),
},
}
} else {
Qr::Text {
@@ -494,7 +539,7 @@ async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<
let qr = qr.replacen('&', "#", 1);
decode_openpgp(context, &qr)
.await
.context("failed to decode {prefix} QR code")
.with_context(|| format!("failed to decode {prefix} QR code"))
}
/// scheme: `DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3`
@@ -541,16 +586,15 @@ fn decode_webrtc_instance(_context: &Context, qr: &str) -> Result<Qr> {
fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
const SOCKS5_DEFAULT_PORT: u16 = 1080;
let mut host: Option<String> = None;
let mut port: u16 = SOCKS5_DEFAULT_PORT;
let mut port: u16 = DEFAULT_SOCKS_PORT;
let mut user: Option<String> = None;
let mut pass: Option<String> = None;
for (key, value) in url.query_pairs() {
if key == "server" {
host = Some(value.to_string());
} else if key == "port" {
port = value.parse().unwrap_or(SOCKS5_DEFAULT_PORT);
port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
} else if key == "user" {
user = Some(value.to_string());
} else if key == "pass" {
@@ -558,16 +602,35 @@ fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
}
}
if let Some(host) = host {
Ok(Qr::Socks5Proxy {
host,
port,
user,
pass,
})
} else {
let Some(host) = host else {
bail!("Bad t.me/socks url: {:?}", url);
}
};
let mut url = "socks5://".to_string();
if let Some(pass) = pass {
url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
url += ":";
url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
url += "@";
};
url += &host;
url += ":";
url += &port.to_string();
Ok(Qr::Proxy { url, host, port })
}
/// Decodes `ss://` URLs for Shadowsocks proxies.
fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
let addr = server_config.addr();
let host = addr.host().to_string();
let port = addr.port();
Ok(Qr::Proxy {
url: qr.to_string(),
host,
port,
})
}
/// Decodes a [`DCBACKUP2_SCHEME`] QR code.
@@ -655,28 +718,22 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
.set_config_internal(Config::WebrtcInstance, Some(&instance_pattern))
.await?;
}
Qr::Socks5Proxy {
host,
port,
user,
pass,
} => {
// disable proxy before changing settings to not use a combination of old and new
Qr::Proxy { url, .. } => {
let old_proxy_url_value = context
.get_config(Config::ProxyUrl)
.await?
.unwrap_or_default();
let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
.chain(
old_proxy_url_value
.split('\n')
.filter(|s| !s.is_empty() && *s != url),
)
.collect();
context
.set_config_bool(Config::Socks5Enabled, false)
.set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
.await?;
context.set_config(Config::Socks5Host, Some(&host)).await?;
context
.set_config_u32(Config::Socks5Port, u32::from(port))
.await?;
context
.set_config(Config::Socks5User, user.as_deref())
.await?;
context
.set_config(Config::Socks5Password, pass.as_deref())
.await?;
context.set_config_bool(Config::Socks5Enabled, true).await?;
context.set_config_bool(Config::ProxyEnabled, true).await?;
}
Qr::WithdrawVerifyContact {
invitenumber,
@@ -708,7 +765,7 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
token::save(context, token::Namespace::InviteNumber, None, &invitenumber).await?;
token::save(context, token::Namespace::Auth, None, &authcode).await?;
context.sync_qr_code_tokens(None).await?;
context.scheduler.interrupt_smtp().await;
context.scheduler.interrupt_inbox().await;
}
Qr::ReviveVerifyGroup {
invitenumber,
@@ -716,19 +773,16 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
grpid,
..
} => {
let chat_id = get_chat_id_by_grpid(context, &grpid)
.await?
.map(|(chat_id, _protected, _blocked)| chat_id);
token::save(
context,
token::Namespace::InviteNumber,
chat_id,
Some(&grpid),
&invitenumber,
)
.await?;
token::save(context, token::Namespace::Auth, chat_id, &authcode).await?;
context.sync_qr_code_tokens(chat_id).await?;
context.scheduler.interrupt_smtp().await;
token::save(context, token::Namespace::Auth, Some(&grpid), &authcode).await?;
context.sync_qr_code_tokens(Some(&grpid)).await?;
context.scheduler.interrupt_inbox().await;
}
Qr::Login { address, options } => {
configure_from_login_qr(context, &address, options).await?
@@ -908,11 +962,38 @@ mod tests {
async fn test_decode_http() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "http://www.hello.com:80").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "http://www.hello.com:80".to_string(),
host: "www.hello.com".to_string(),
port: 80
}
);
// If it has no explicit port, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "http://www.hello.com").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com".to_string()
url: "http://www.hello.com".to_string(),
}
);
// If it has a path, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "http://www.hello.com/").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com/".to_string(),
}
);
let qr = check_qr(&ctx.ctx, "http://www.hello.com/hello").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com/hello".to_string(),
}
);
@@ -923,11 +1004,38 @@ mod tests {
async fn test_decode_https() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "https://www.hello.com:443").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "https://www.hello.com:443".to_string(),
host: "www.hello.com".to_string(),
port: 443
}
);
// If it has no explicit port, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "https://www.hello.com").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com".to_string()
url: "https://www.hello.com".to_string(),
}
);
// If it has a path, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "https://www.hello.com/").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com/".to_string(),
}
);
let qr = check_qr(&ctx.ctx, "https://www.hello.com/hello").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com/hello".to_string(),
}
);
@@ -1515,33 +1623,30 @@ mod tests {
let qr = check_qr(&t, "https://t.me/socks?server=84.53.239.95&port=4145").await?;
assert_eq!(
qr,
Qr::Socks5Proxy {
Qr::Proxy {
url: "socks5://84.53.239.95:4145".to_string(),
host: "84.53.239.95".to_string(),
port: 4145,
user: None,
pass: None,
}
);
let qr = check_qr(&t, "https://t.me/socks?server=foo.bar&port=123").await?;
assert_eq!(
qr,
Qr::Socks5Proxy {
Qr::Proxy {
url: "socks5://foo.bar:123".to_string(),
host: "foo.bar".to_string(),
port: 123,
user: None,
pass: None,
}
);
let qr = check_qr(&t, "https://t.me/socks?server=foo.baz").await?;
assert_eq!(
qr,
Qr::Socks5Proxy {
Qr::Proxy {
url: "socks5://foo.baz:1080".to_string(),
host: "foo.baz".to_string(),
port: 1080,
user: None,
pass: None,
}
);
@@ -1552,11 +1657,10 @@ mod tests {
.await?;
assert_eq!(
qr,
Qr::Socks5Proxy {
Qr::Proxy {
url: "socks5://ada:ms%21%2F%24@foo.baz:12345".to_string(),
host: "foo.baz".to_string(),
port: 12345,
user: Some("ada".to_string()),
pass: Some("ms!/$".to_string()),
}
);
@@ -1604,10 +1708,6 @@ mod tests {
assert!(res.is_err());
assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none());
let res = set_config_from_qr(&ctx.ctx, "https://no.qr").await;
assert!(res.is_err());
assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none());
let res = set_config_from_qr(&ctx.ctx, "dcwebrtc:https://example.org/").await;
assert!(res.is_ok());
assert_eq!(
@@ -1627,53 +1727,102 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_socks5_proxy_config_from_qr() -> Result<()> {
async fn test_set_proxy_config_from_qr() -> Result<()> {
let t = TestContext::new().await;
assert_eq!(t.get_config_bool(Config::Socks5Enabled).await?, false);
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, false);
let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::Socks5Enabled).await?, true);
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::Socks5Host).await?,
Some("foo".to_string())
t.get_config(Config::ProxyUrl).await?,
Some("socks5://foo:666".to_string())
);
assert_eq!(t.get_config_u32(Config::Socks5Port).await?, 666);
assert_eq!(t.get_config(Config::Socks5User).await?, None);
assert_eq!(t.get_config(Config::Socks5Password).await?, None);
// make sure, user&password are reset when not specified in the URL
t.set_config(Config::Socks5User, Some("alice")).await?;
t.set_config(Config::Socks5Password, Some("secret")).await?;
// Test URL without port.
let res = set_config_from_qr(&t, "https://t.me/socks?server=1.2.3.4").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::Socks5Enabled).await?, true);
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::Socks5Host).await?,
Some("1.2.3.4".to_string())
t.get_config(Config::ProxyUrl).await?,
Some("socks5://1.2.3.4:1080\nsocks5://foo:666".to_string())
);
assert_eq!(t.get_config_u32(Config::Socks5Port).await?, 1080);
assert_eq!(t.get_config(Config::Socks5User).await?, None);
assert_eq!(t.get_config(Config::Socks5Password).await?, None);
// make sure, user&password are set when specified in the URL
// Password is an URL-encoded "x&%$X".
let res =
set_config_from_qr(&t, "https://t.me/socks?server=jau&user=Da&pass=x%26%25%24X").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::Socks5Enabled).await?, true);
assert_eq!(
t.get_config(Config::Socks5Host).await?,
Some("jau".to_string())
t.get_config(Config::ProxyUrl).await?,
Some(
"socks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080\nsocks5://foo:666"
.to_string()
)
);
assert_eq!(t.get_config_u32(Config::Socks5Port).await?, 1080);
// Scanning existing proxy brings it to the top in the list.
let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::Socks5User).await?,
Some("Da".to_string())
t.get_config(Config::ProxyUrl).await?,
Some(
"socks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080"
.to_string()
)
);
set_config_from_qr(
&t,
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1",
)
.await?;
assert_eq!(
t.get_config(Config::Socks5Password).await?,
Some("x&%$X".to_string())
t.get_config(Config::ProxyUrl).await?,
Some(
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080"
.to_string()
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_shadowsocks() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1",
)
.await?;
assert_eq!(
qr,
Qr::Proxy {
url: "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1".to_string(),
host: "192.168.100.1".to_string(),
port: 8888,
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_socks5() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "socks5://127.0.0.1:9050").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://127.0.0.1:9050".to_string(),
host: "127.0.0.1".to_string(),
port: 9050,
}
);
Ok(())

View File

@@ -72,7 +72,7 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
.unwrap_or(url_without_scheme);
let addr = payload
.split(|c| c == '?' || c == '/')
.split(['?', '/'])
.next()
.context("invalid DCLOGIN payload E3")?;

View File

@@ -197,7 +197,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quota_needs_update() {
async fn test_quota_needs_update() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
const TIMEOUT: u64 = 60;
@@ -214,5 +214,14 @@ mod tests {
modified: tools::Time::now(),
});
assert!(!t.quota_needs_update(TIMEOUT).await);
t.evtracker.clear_events();
t.set_primary_self_addr("new@addr").await?;
assert!(t.quota.read().await.is_none());
t.evtracker
.get_matching(|evt| matches!(evt, EventType::ConnectivityChanged))
.await;
assert!(t.quota_needs_update(TIMEOUT).await);
Ok(())
}
}

View File

@@ -35,7 +35,7 @@ use crate::peerstate::Peerstate;
use crate::reaction::{set_msg_reaction, Reaction};
use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on_other_device};
use crate::simplify;
use crate::sql;
use crate::sql::{self, params_iter};
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{self, buf_compress, remove_subject_prefix};
@@ -1014,7 +1014,10 @@ async fn add_parts(
}
}
state = if seen
state = if replace_msg_id.is_some() {
// TODO a comment explaining why we're doing this would be nice
MessageState::InNoticed
} else if seen
|| fetching_existing_messages
|| is_mdn
|| is_reaction
@@ -1562,7 +1565,7 @@ INSERT INTO msgs
ON CONFLICT (id) DO UPDATE
SET rfc724_mid=excluded.rfc724_mid, chat_id=excluded.chat_id,
from_id=excluded.from_id, to_id=excluded.to_id, timestamp_sent=excluded.timestamp_sent,
type=excluded.type, msgrmsg=excluded.msgrmsg,
type=excluded.type, state=min(state,excluded.state), msgrmsg=excluded.msgrmsg,
txt=excluded.txt, txt_normalized=excluded.txt_normalized, subject=excluded.subject,
txt_raw=excluded.txt_raw, param=excluded.param,
hidden=excluded.hidden,bytes=excluded.bytes, mime_headers=excluded.mime_headers,
@@ -1613,7 +1616,7 @@ RETURNING id
} else {
DownloadState::Done
},
mime_parser.hop_info
mime_parser.hop_info,
],
|row| {
let msg_id: MsgId = row.get(0)?;
@@ -1827,22 +1830,82 @@ async fn lookup_chat_or_create_adhoc_group(
// Try to assign to a chat based on In-Reply-To/References.
lookup_chat_by_reply(context, mime_parser, parent, to_ids, from_id).await?
{
Ok(Some((new_chat_id, new_chat_id_blocked)))
} else if allow_creation {
// Try to create an ad hoc group.
create_adhoc_group(
context,
mime_parser,
create_blocked,
from_id,
to_ids,
is_partial_download,
)
.await
.context("Could not create ad hoc group")
} else {
Ok(None)
return Ok(Some((new_chat_id, new_chat_id_blocked)));
}
// Partial download may be an encrypted message with protected Subject header. We do not want to
// create a group with "..." or "Encrypted message" as a subject. The same is for undecipherable
// messages. Instead, assign the message to 1:1 chat with the sender.
if is_partial_download {
info!(
context,
"Ad-hoc group cannot be created from partial download."
);
return Ok(None);
}
if mime_parser.decrypting_failed {
warn!(
context,
"Not creating ad-hoc group for message that cannot be decrypted."
);
return Ok(None);
}
let grpname = mime_parser
.get_subject()
.map(|s| remove_subject_prefix(&s))
.unwrap_or_else(|| "👥📧".to_string());
let mut contact_ids = Vec::with_capacity(to_ids.len() + 1);
contact_ids.extend(to_ids);
if !contact_ids.contains(&from_id) {
contact_ids.push(from_id);
}
if let Some((chat_id, blocked)) = context
.sql
.query_row_optional(
&format!(
"SELECT c.id, c.blocked
FROM chats c INNER JOIN msgs m ON c.id=m.chat_id
WHERE m.hidden=0 AND c.grpid='' AND c.name=?
AND (SELECT COUNT(*) FROM chats_contacts
WHERE chat_id=c.id)=?
AND (SELECT COUNT(*) FROM chats_contacts
WHERE chat_id=c.id
AND contact_id NOT IN ({}))=0
ORDER BY m.timestamp DESC",
sql::repeat_vars(contact_ids.len()),
),
rusqlite::params_from_iter(
params_iter(&[&grpname])
.chain(params_iter(&[contact_ids.len()]))
.chain(params_iter(&contact_ids)),
),
|row| {
let id: ChatId = row.get(0)?;
let blocked: Blocked = row.get(1)?;
Ok((id, blocked))
},
)
.await?
{
info!(
context,
"Assigning message to ad-hoc group {chat_id} with matching name and members."
);
return Ok(Some((chat_id, blocked)));
}
if !allow_creation {
return Ok(None);
}
create_adhoc_group(
context,
mime_parser,
create_blocked,
from_id,
to_ids,
&grpname,
)
.await
.context("Could not create ad hoc group")
}
/// If this method returns true, the message shall be assigned to the 1:1 chat with the sender.
@@ -2512,19 +2575,8 @@ async fn create_adhoc_group(
create_blocked: Blocked,
from_id: ContactId,
to_ids: &[ContactId],
is_partial_download: bool,
grpname: &str,
) -> Result<Option<(ChatId, Blocked)>> {
if is_partial_download {
// Partial download may be an encrypted message with protected Subject header.
//
// We do not want to create a group with "..." or "Encrypted message" as a subject.
info!(
context,
"Ad-hoc group cannot be created from partial download."
);
return Ok(None);
}
let mut member_ids: Vec<ContactId> = to_ids.to_vec();
if !member_ids.contains(&(from_id)) {
member_ids.push(from_id);
@@ -2536,22 +2588,6 @@ async fn create_adhoc_group(
if mime_parser.is_mailinglist_message() {
return Ok(None);
}
if mime_parser.decrypting_failed {
// Do not create a new ad-hoc group if the message cannot be
// decrypted.
//
// The subject may be encrypted and contain a placeholder such
// as "...". It can also be a COI group, with encrypted
// Chat-Group-ID and incompatible Message-ID format.
//
// Instead, assign the message to 1:1 chat with the sender.
warn!(
context,
"Not creating ad-hoc group for message that cannot be decrypted."
);
return Ok(None);
}
if mime_parser
.get_header(HeaderDef::ChatGroupMemberRemoved)
.is_some()
@@ -2566,16 +2602,11 @@ async fn create_adhoc_group(
return Ok(None);
}
let grpname = mime_parser
.get_subject()
.map(|s| remove_subject_prefix(&s))
.unwrap_or_else(|| "👥📧".to_string());
let new_chat_id: ChatId = ChatId::create_multiuser_record(
context,
Chattype::Group,
"", // Ad hoc groups have no ID.
&grpname,
grpname,
create_blocked,
ProtectionStatus::Unprotected,
None,

View File

@@ -204,6 +204,71 @@ async fn test_adhoc_group_show_all() {
assert_eq!(chat::get_chat_contacts(&t, chat_id).await.unwrap().len(), 3);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_groups_merge() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org, claire@example.com\n\
Message-ID: <1111@example.net>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
Subject: New thread\n\
\n\
The first of us should create a thread as discussed\n",
false,
)
.await?;
receive_imf(
alice,
b"From: alice@example.org\n\
To: bob@example.net, claire@example.com\n\
Message-ID: <2222@example.org>\n\
Date: Sun, 22 Mar 2020 22:37:58 +0000\n\
Subject: New thread\n\
\n\
The first of us should create a thread as discussed\n",
false,
)
.await?;
let chats = Chatlist::try_load(alice, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let chat_id = chats.get_chat_id(0)?;
assert_eq!(chat_id.get_msg_cnt(alice).await?, 2);
// If member list doesn't match, threads aren't merged.
receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org, claire@example.com, fiona@example.net\n\
Message-ID: <3333@example.net>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
Subject: New thread\n\
\n\
This is another thread, with Fiona\n",
false,
)
.await?;
let chats = Chatlist::try_load(alice, 0, None, None).await?;
assert_eq!(chats.len(), 2);
receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org, fiona@example.net\n\
Message-ID: <4444@example.net>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
Subject: New thread\n\
\n\
This is yet another thread, with Fiona and 0 Claires\n",
false,
)
.await?;
let chats = Chatlist::try_load(alice, 0, None, None).await?;
assert_eq!(chats.len(), 3);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_read_receipt_and_unarchive() -> Result<()> {
// create alice's account
@@ -2107,6 +2172,19 @@ async fn test_no_unencrypted_name_in_self_chat() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_smtp_job_for_self_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
bob.set_config_bool(Config::BccSelf, false).await?;
let chat_id = bob.get_self_chat().await.id;
let mut msg = Message::new(Viewtype::Text);
msg.text = "Happy birthday to me".to_string();
chat::send_msg(bob, chat_id, &mut msg).await?;
assert!(bob.pop_sent_msg_opt(Duration::ZERO).await.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_outgoing_classic_mail_creates_chat() {
let alice = TestContext::new_alice().await;
@@ -3085,20 +3163,21 @@ Reply from different address
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_long_and_duplicated_filenames() -> Result<()> {
async fn test_weird_and_duplicated_filenames() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
for filename_sent in &[
"foo.bar very long file name test baz.tar.gz",
"foobarabababababababbababababverylongfilenametestbaz.tar.gz",
"foo.barabababababababbababababverylongfilenametestbaz.tar.gz",
"fooo...tar.gz",
"foo. .tar.gz",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.tar.gz",
"a.tar.gz",
"a.tar.gz",
"a.a..a.a.a.a.tar.gz",
"a. tar.tar.gz",
] {
let attachment = alice.blobdir.join(filename_sent);
let content = format!("File content of {filename_sent}");

View File

@@ -433,36 +433,33 @@ async fn inbox_loop(
}
/// Convert folder meaning
/// used internally by [fetch_idle] and [Context::background_fetch]
/// used internally by [fetch_idle] and [Context::background_fetch].
///
/// Returns folder configuration key and folder name
/// if such folder is configured, `Ok(None)` otherwise.
pub async fn convert_folder_meaning(
ctx: &Context,
folder_meaning: FolderMeaning,
) -> Result<(Config, String)> {
) -> Result<Option<(Config, String)>> {
let folder_config = match folder_meaning.to_config() {
Some(c) => c,
None => {
bail!("Bad folder meaning: {}", folder_meaning);
// Such folder cannot be configured,
// e.g. a `FolderMeaning::Spam` folder.
return Ok(None);
}
};
let folder = match ctx.get_config(folder_config).await {
Ok(folder) => folder,
Err(err) => {
bail!(
"Can not watch {} folder, failed to retrieve config: {:#}",
folder_config,
err
);
}
};
let folder = ctx
.get_config(folder_config)
.await
.with_context(|| format!("Failed to retrieve {folder_config} folder"))?;
let watch_folder = if let Some(watch_folder) = folder {
watch_folder
if let Some(watch_folder) = folder {
Ok(Some((folder_config, watch_folder)))
} else {
bail!("Can not watch {} folder, not set", folder_config);
};
Ok((folder_config, watch_folder))
Ok(None)
}
}
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
@@ -554,20 +551,32 @@ async fn fetch_idle(
mut session: Session,
folder_meaning: FolderMeaning,
) -> Result<Session> {
let (folder_config, watch_folder) = match convert_folder_meaning(ctx, folder_meaning).await {
Ok(meaning) => meaning,
Err(err) => {
// Warning instead of error because the folder may not be configured.
// For example, this happens if the server does not have Sent folder
// but watching Sent folder is enabled.
warn!(ctx, "Error converting IMAP Folder name: {err:#}.");
connection.connectivity.set_not_configured(ctx).await;
connection.idle_interrupt_receiver.recv().await.ok();
return Err(err);
}
let Some((folder_config, watch_folder)) = convert_folder_meaning(ctx, folder_meaning).await?
else {
// 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.idle_interrupt_receiver.recv().await.ok();
bail!("Cannot fetch folder {folder_meaning} because it is not configured");
};
if folder_config == Config::ConfiguredInboxFolder {
let mvbox;
let syncbox = match ctx.should_move_sync_msgs().await? {
false => &watch_folder,
true => {
mvbox = ctx.get_config(Config::ConfiguredMvboxFolder).await?;
mvbox.as_deref().unwrap_or(&watch_folder)
}
};
session
.send_sync_msgs(ctx, syncbox)
.await
.context("fetch_idle: send_sync_msgs")
.log_err(ctx)
.ok();
session
.store_seen_flags_on_imap(ctx)
.await

View File

@@ -1,13 +1,13 @@
//! Implementation of [SecureJoin protocols](https://securejoin.delta.chat/).
use anyhow::{bail, Context as _, Error, Result};
use anyhow::{bail, ensure, Context as _, Error, Result};
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use crate::aheader::EncryptPreference;
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
use crate::chat::{self, get_chat_id_by_grpid, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::Blocked;
use crate::constants::{Blocked, Chattype};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::e2ee::ensure_secret_key_exists;
@@ -60,13 +60,29 @@ pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Resu
ensure_secret_key_exists(context).await.ok();
// invitenumber will be used to allow starting the handshake,
// auth will be used to verify the fingerprint
let sync_token = token::lookup(context, Namespace::InviteNumber, group)
let chat = match group {
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}"
);
Some(chat)
}
None => None,
};
let grpid = chat.as_ref().map(|c| c.grpid.as_str());
let sync_token = token::lookup(context, Namespace::InviteNumber, grpid)
.await?
.is_none();
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, group).await?;
let auth = token::lookup_or_new(context, Namespace::Auth, group).await?;
// invitenumber will be used to allow starting the handshake,
// auth will be used to verify the fingerprint
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, grpid).await?;
let auth = token::lookup_or_new(context, Namespace::Auth, grpid).await?;
let self_addr = context.get_primary_self_addr().await?;
let self_name = context
.get_config(Config::Displayname)
@@ -85,20 +101,15 @@ pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Resu
let self_name_urlencoded =
utf8_percent_encode(&self_name, NON_ALPHANUMERIC_WITHOUT_DOT).to_string();
let qr = if let Some(group) = group {
let qr = if let Some(chat) = chat {
// parameters used: a=g=x=i=s=
let chat = Chat::load_from_db(context, group).await?;
if chat.grpid.is_empty() {
bail!(
"can't generate securejoin QR code for ad-hoc group {}",
group
);
}
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.id)).await?;
context.scheduler.interrupt_smtp().await;
context
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await?;
context.scheduler.interrupt_inbox().await;
}
format!(
"OPENPGP4FPR:{}#a={}&g={}&x={}&i={}&s={}",
@@ -113,7 +124,7 @@ pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Resu
// parameters used: a=n=i=s=
if sync_token {
context.sync_qr_code_tokens(None).await?;
context.scheduler.interrupt_smtp().await;
context.scheduler.interrupt_inbox().await;
}
format!(
"OPENPGP4FPR:{}#a={}&n={}&i={}&s={}",
@@ -400,13 +411,23 @@ pub(crate) async fn handle_securejoin_handshake(
);
return Ok(HandshakeMessage::Ignore);
};
let Some(group_chat_id) = token::auth_chat_id(context, auth).await? else {
let Some(grpid) = token::auth_foreign_key(context, auth).await? else {
warn!(
context,
"Ignoring {step} message because of invalid auth code."
);
return Ok(HandshakeMessage::Ignore);
};
let group_chat_id = match grpid.as_str() {
"" => None,
id => {
let Some((chat_id, ..)) = get_chat_id_by_grpid(context, id).await? else {
warn!(context, "Ignoring {step} message: unknown grpid {id}.",);
return Ok(HandshakeMessage::Ignore);
};
Some(chat_id)
}
};
let contact_addr = Contact::get_by_id(context, contact_id)
.await?
@@ -432,7 +453,20 @@ pub(crate) async fn handle_securejoin_handshake(
info!(context, "Auth verified.",);
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
inviter_progress(context, contact_id, 600);
if group_chat_id.is_unset() {
if let Some(group_chat_id) = group_chat_id {
// Join group.
secure_connection_established(
context,
contact_id,
group_chat_id,
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);
} else {
// Setup verified contact.
secure_connection_established(
context,
@@ -446,19 +480,6 @@ pub(crate) async fn handle_securejoin_handshake(
.context("failed sending vc-contact-confirm message")?;
inviter_progress(context, contact_id, 1000);
} else {
// Join group.
secure_connection_established(
context,
contact_id,
group_chat_id,
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);
}
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
}
@@ -675,7 +696,10 @@ async fn could_not_establish_secure_connection(
details: &str,
) -> Result<()> {
let contact = Contact::get_by_id(context, contact_id).await?;
let msg = stock_str::contact_not_verified(context, &contact).await;
let mut msg = stock_str::contact_not_verified(context, &contact).await;
msg += " (";
msg += details;
msg += ")";
chat::add_info_msg(context, chat_id, &msg, time()).await?;
warn!(
context,

View File

@@ -47,7 +47,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
BobState::start_protocol(context, invite.clone(), chat_id).await?;
for state in aborted_states {
error!(context, "Aborting previously unfinished QR Join process.");
state.notify_aborted(context, "new QR scanned").await?;
state.notify_aborted(context, "New QR code scanned").await?;
state.emit_progress(context, JoinerProgress::Error);
}
if matches!(stage, BobHandshakeStage::RequestWithAuthSent) {
@@ -194,7 +194,10 @@ impl BobState {
/// This creates an info message in the chat being joined.
async fn notify_aborted(&self, context: &Context, why: &str) -> Result<()> {
let contact = Contact::get_by_id(context, self.invite().contact_id()).await?;
let msg = stock_str::contact_not_verified(context, &contact).await;
let mut msg = stock_str::contact_not_verified(context, &contact).await;
msg += " (";
msg += why;
msg += ")";
let chat_id = self.joining_chat_id(context).await?;
chat::add_info_msg(context, chat_id, &msg, time()).await?;
warn!(

View File

@@ -18,9 +18,9 @@ use crate::login_param::{ConfiguredLoginParam, ConfiguredServerLoginParam};
use crate::message::Message;
use crate::message::{self, MsgId};
use crate::mimefactory::MimeFactory;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionBufStream;
use crate::scheduler::connectivity::ConnectivityStore;
use crate::socks::Socks5Config;
use crate::sql;
use crate::stock_str::unencrypted_email;
use crate::tools::{self, time_elapsed};
@@ -95,7 +95,7 @@ impl Smtp {
context,
&lp.smtp,
&lp.smtp_password,
&lp.socks5_config,
&lp.proxy_config,
&lp.addr,
lp.strict_tls(),
lp.oauth2,
@@ -110,7 +110,7 @@ impl Smtp {
context: &Context,
login_params: &[ConfiguredServerLoginParam],
password: &str,
socks5_config: &Option<Socks5Config>,
proxy_config: &Option<ProxyConfig>,
addr: &str,
strict_tls: bool,
oauth2: bool,
@@ -130,7 +130,7 @@ impl Smtp {
info!(context, "SMTP trying to connect to {}.", &lp.connection);
let transport = match connect::connect_and_auth(
context,
socks5_config,
proxy_config,
strict_tls,
lp.connection.clone(),
oauth2,
@@ -142,7 +142,7 @@ impl Smtp {
{
Ok(transport) => transport,
Err(err) => {
warn!(context, "SMTP failed to connect: {err:#}.");
warn!(context, "SMTP failed to connect and authenticate: {err:#}.");
continue;
}
};
@@ -485,7 +485,6 @@ pub(crate) async fn send_smtp_messages(context: &Context, connection: &mut Smtp)
let ratelimited = if context.ratelimit.read().await.can_send() {
// add status updates and sync messages to end of sending queue
context.flush_status_updates().await?;
context.send_sync_msg().await?;
false
} else {
true

View File

@@ -4,18 +4,18 @@ use std::net::SocketAddr;
use anyhow::{bail, Context as _, Result};
use async_smtp::{SmtpClient, SmtpTransport};
use tokio::io::BufStream;
use tokio::io::{AsyncBufRead, AsyncWrite, BufStream};
use crate::context::Context;
use crate::login_param::{ConnectionCandidate, ConnectionSecurity};
use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp};
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionBufStream;
use crate::net::tls::wrap_tls;
use crate::net::{
connect_tcp_inner, connect_tls_inner, run_connection_attempts, update_connection_history,
};
use crate::oauth2::get_oauth2_access_token;
use crate::socks::Socks5Config;
use crate::tools::time;
/// Converts port number to ALPN list.
@@ -28,10 +28,27 @@ fn alpn(port: u16) -> &'static [&'static str] {
}
}
// Constructs a new SMTP transport
// over a stream with already skipped SMTP greeting.
async fn new_smtp_transport<S: AsyncBufRead + AsyncWrite + Unpin>(
stream: S,
) -> Result<SmtpTransport<S>> {
// We always read the greeting manually to unify
// the cases of STARTTLS where the greeting is
// sent outside the encrypted channel and implicit TLS
// where the greeting is sent after establishing TLS channel.
let client = SmtpClient::new().smtp_utf8(true).without_greeting();
let transport = SmtpTransport::new(client, stream)
.await
.context("Failed to send EHLO command")?;
Ok(transport)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn connect_and_auth(
context: &Context,
socks5_config: &Option<Socks5Config>,
proxy_config: &Option<ProxyConfig>,
strict_tls: bool,
candidate: ConnectionCandidate,
oauth2: bool,
@@ -39,17 +56,17 @@ pub(crate) async fn connect_and_auth(
user: &str,
password: &str,
) -> Result<SmtpTransport<Box<dyn SessionBufStream>>> {
let session_stream =
connect_stream(context, socks5_config.clone(), strict_tls, candidate).await?;
let client = async_smtp::SmtpClient::new()
.smtp_utf8(true)
.without_greeting();
let mut transport = SmtpTransport::new(client, session_stream).await?;
let session_stream = connect_stream(context, proxy_config.clone(), strict_tls, candidate)
.await
.context("SMTP failed to connect")?;
let mut transport = new_smtp_transport(session_stream).await?;
// Authenticate.
let (creds, mechanism) = if oauth2 {
// oauth2
let access_token = get_oauth2_access_token(context, addr, password, false).await?;
let access_token = get_oauth2_access_token(context, addr, password, false)
.await
.context("SMTP failed to get OAUTH2 access token")?;
if access_token.is_none() {
bail!("SMTP OAuth 2 error {}", addr);
}
@@ -70,7 +87,10 @@ pub(crate) async fn connect_and_auth(
],
)
};
transport.try_login(&creds, &mechanism).await?;
transport
.try_login(&creds, &mechanism)
.await
.context("SMTP failed to login")?;
Ok(transport)
}
@@ -127,7 +147,7 @@ async fn connection_attempt(
/// to unify the result regardless of whether TLS or STARTTLS is used.
async fn connect_stream(
context: &Context,
socks5_config: Option<Socks5Config>,
proxy_config: Option<ProxyConfig>,
strict_tls: bool,
candidate: ConnectionCandidate,
) -> Result<Box<dyn SessionBufStream>> {
@@ -135,18 +155,17 @@ async fn connect_stream(
let port = candidate.port;
let security = candidate.security;
if let Some(socks5_config) = socks5_config {
if let Some(proxy_config) = proxy_config {
let stream = match security {
ConnectionSecurity::Tls => {
connect_secure_socks5(context, host, port, strict_tls, socks5_config.clone())
.await?
connect_secure_proxy(context, host, port, strict_tls, proxy_config.clone()).await?
}
ConnectionSecurity::Starttls => {
connect_starttls_socks5(context, host, port, strict_tls, socks5_config.clone())
connect_starttls_proxy(context, host, port, strict_tls, proxy_config.clone())
.await?
}
ConnectionSecurity::Plain => {
connect_insecure_socks5(context, host, port, socks5_config.clone()).await?
connect_insecure_proxy(context, host, port, proxy_config.clone()).await?
}
};
Ok(stream)
@@ -178,51 +197,55 @@ async fn skip_smtp_greeting<R: tokio::io::AsyncBufReadExt + Unpin>(stream: &mut
let mut line = String::with_capacity(512);
loop {
line.clear();
let read = stream.read_line(&mut line).await?;
let read = stream
.read_line(&mut line)
.await
.context("Failed to read from stream while waiting for SMTP greeting")?;
if read == 0 {
bail!("Unexpected EOF while reading SMTP greeting.");
bail!("Unexpected EOF while reading SMTP greeting");
}
if line.starts_with("220-") {
continue;
} else if line.starts_with("220 ") {
return Ok(());
} else {
bail!("Unexpected greeting: {line:?}.");
bail!("Unexpected greeting: {line:?}");
}
}
}
async fn connect_secure_socks5(
async fn connect_secure_proxy(
context: &Context,
hostname: &str,
port: u16,
strict_tls: bool,
socks5_config: Socks5Config,
proxy_config: ProxyConfig,
) -> Result<Box<dyn SessionBufStream>> {
let socks5_stream = socks5_config
let proxy_stream = proxy_config
.connect(context, hostname, port, strict_tls)
.await?;
let tls_stream = wrap_tls(strict_tls, hostname, alpn(port), socks5_stream).await?;
let tls_stream = wrap_tls(strict_tls, hostname, alpn(port), proxy_stream).await?;
let mut buffered_stream = BufStream::new(tls_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let session_stream: Box<dyn SessionBufStream> = Box::new(buffered_stream);
Ok(session_stream)
}
async fn connect_starttls_socks5(
async fn connect_starttls_proxy(
context: &Context,
hostname: &str,
port: u16,
strict_tls: bool,
socks5_config: Socks5Config,
proxy_config: ProxyConfig,
) -> Result<Box<dyn SessionBufStream>> {
let socks5_stream = socks5_config
let proxy_stream = proxy_config
.connect(context, hostname, port, strict_tls)
.await?;
// Run STARTTLS command and convert the client back into a stream.
let client = SmtpClient::new().smtp_utf8(true);
let transport = SmtpTransport::new(client, BufStream::new(socks5_stream)).await?;
let mut buffered_stream = BufStream::new(proxy_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let transport = new_smtp_transport(buffered_stream).await?;
let tcp_stream = transport.starttls().await?.into_inner();
let tls_stream = wrap_tls(strict_tls, hostname, &[], tcp_stream)
.await
@@ -232,16 +255,14 @@ async fn connect_starttls_socks5(
Ok(session_stream)
}
async fn connect_insecure_socks5(
async fn connect_insecure_proxy(
context: &Context,
hostname: &str,
port: u16,
socks5_config: Socks5Config,
proxy_config: ProxyConfig,
) -> Result<Box<dyn SessionBufStream>> {
let socks5_stream = socks5_config
.connect(context, hostname, port, false)
.await?;
let mut buffered_stream = BufStream::new(socks5_stream);
let proxy_stream = proxy_config.connect(context, hostname, port, false).await?;
let mut buffered_stream = BufStream::new(proxy_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let session_stream: Box<dyn SessionBufStream> = Box::new(buffered_stream);
Ok(session_stream)
@@ -267,8 +288,9 @@ async fn connect_starttls(
let tcp_stream = connect_tcp_inner(addr).await?;
// Run STARTTLS command and convert the client back into a stream.
let client = async_smtp::SmtpClient::new().smtp_utf8(true);
let transport = async_smtp::SmtpTransport::new(client, BufStream::new(tcp_stream)).await?;
let mut buffered_stream = BufStream::new(tcp_stream);
skip_smtp_greeting(&mut buffered_stream).await?;
let transport = new_smtp_transport(buffered_stream).await?;
let tcp_stream = transport.starttls().await?.into_inner();
let tls_stream = wrap_tls(strict_tls, host, &[], tcp_stream)
.await

View File

@@ -1,101 +0,0 @@
//! # SOCKS5 support.
use std::fmt;
use std::pin::Pin;
use anyhow::Result;
use fast_socks5::client::{Config, Socks5Stream};
use fast_socks5::util::target_addr::ToTargetAddr;
use fast_socks5::AuthenticationMethod;
use fast_socks5::Socks5Command;
use tokio::net::TcpStream;
use tokio_io_timeout::TimeoutStream;
use crate::context::Context;
use crate::net::connect_tcp;
use crate::sql::Sql;
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct Socks5Config {
pub host: String,
pub port: u16,
pub user_password: Option<(String, String)>,
}
impl Socks5Config {
/// Reads SOCKS5 configuration from the database.
pub async fn from_database(sql: &Sql) -> Result<Option<Self>> {
let enabled = sql.get_raw_config_bool("socks5_enabled").await?;
if enabled {
let host = sql.get_raw_config("socks5_host").await?.unwrap_or_default();
let port: u16 = sql
.get_raw_config_int("socks5_port")
.await?
.unwrap_or_default() as u16;
let user = sql.get_raw_config("socks5_user").await?.unwrap_or_default();
let password = sql
.get_raw_config("socks5_password")
.await?
.unwrap_or_default();
let socks5_config = Self {
host,
port,
user_password: if !user.is_empty() {
Some((user, password))
} else {
None
},
};
Ok(Some(socks5_config))
} else {
Ok(None)
}
}
/// If `load_dns_cache` is true, loads cached DNS resolution results.
/// Use this only if the connection is going to be protected with TLS checks.
pub async fn connect(
&self,
context: &Context,
target_host: &str,
target_port: u16,
load_dns_cache: bool,
) -> Result<Socks5Stream<Pin<Box<TimeoutStream<TcpStream>>>>> {
let tcp_stream = connect_tcp(context, &self.host, self.port, load_dns_cache).await?;
let authentication_method = if let Some((username, password)) = self.user_password.as_ref()
{
Some(AuthenticationMethod::Password {
username: username.into(),
password: password.into(),
})
} else {
None
};
let mut socks_stream =
Socks5Stream::use_stream(tcp_stream, authentication_method, Config::default()).await?;
let target_addr = (target_host, target_port).to_target_addr()?;
socks_stream
.request(Socks5Command::TCPConnect, target_addr)
.await?;
Ok(socks_stream)
}
}
impl fmt::Display for Socks5Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"host:{},port:{},user_password:{}",
self.host,
self.port,
if let Some(user_password) = self.user_password.clone() {
format!("user: {}, password: ***", user_password.0)
} else {
"user: None".to_string()
}
)
}
}

View File

@@ -558,15 +558,13 @@ impl Sql {
self.call(move |conn| match conn.query_row(sql.as_ref(), params, f) {
Ok(res) => Ok(Some(res)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(rusqlite::Error::InvalidColumnType(_, _, rusqlite::types::Type::Null)) => Ok(None),
Err(err) => Err(err.into()),
})
.await
}
/// Executes a query which is expected to return one row and one
/// column. If the query does not return a value or returns SQL
/// `NULL`, returns `Ok(None)`.
/// column. If the query does not return any rows, returns `Ok(None)`.
pub async fn query_get_value<T>(
&self,
query: &str,

View File

@@ -971,6 +971,81 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
.await?;
}
inc_and_check(&mut migration_version, 118)?;
if dbversion < migration_version {
sql.execute_migration(
"CREATE TABLE tokens_new (
id INTEGER PRIMARY KEY,
namespc INTEGER DEFAULT 0,
foreign_key TEXT DEFAULT '',
token TEXT DEFAULT '',
timestamp INTEGER DEFAULT 0
) STRICT;
INSERT INTO tokens_new
SELECT t.id, t.namespc, IFNULL(c.grpid, ''), t.token, t.timestamp
FROM tokens t LEFT JOIN chats c ON t.foreign_id=c.id;
DROP TABLE tokens;
ALTER TABLE tokens_new RENAME TO tokens;",
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 119)?;
if dbversion < migration_version {
sql.execute_migration(
"CREATE TABLE imap_send (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mime TEXT NOT NULL, -- Message content
msg_id INTEGER NOT NULL, -- ID of the message in the `msgs` table
attempts INTEGER NOT NULL DEFAULT 0 -- Number of failed attempts to send the message
)",
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 120)?;
if dbversion < migration_version {
// Core 1.143.0 changed the default for `delete_server_after`
// to delete immediately (`1`) for chatmail accounts that don't have multidevice
// and updating to `0` when backup is exported.
//
// Since we don't know if existing configurations
// are multidevice, we set `delete_server_after` for them
// to the old default of `0`, so only new configurations are
// affected by the default change.
//
// `INSERT OR IGNORE` works
// because `keyname` was made UNIQUE in migration 106.
sql.execute_migration(
"INSERT OR IGNORE INTO config (keyname, value)
SELECT 'delete_server_after', '0'
FROM config WHERE keyname='configured'
",
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 121)?;
if dbversion < migration_version {
sql.execute_migration(
"CREATE INDEX chats_index4 ON chats (name)",
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 122)?;
if dbversion < migration_version {
sql.execute_migration(
"ALTER TABLE tokens ADD COLUMN foreign_id INTEGER NOT NULL DEFAULT 0",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?

View File

@@ -4,7 +4,7 @@ use anyhow::Result;
use lettre_email::PartBuilder;
use serde::{Deserialize, Serialize};
use crate::chat::{self, Chat, ChatId};
use crate::chat::{self, ChatId};
use crate::config::Config;
use crate::constants::Blocked;
use crate::contact::ContactId;
@@ -117,36 +117,22 @@ impl Context {
Ok(())
}
/// Adds most recent qr-code tokens for a given chat to the list of items to be synced.
/// If device synchronization is disabled,
/// Adds most recent qr-code tokens for the given group or self-contact to the list of items to
/// be synced. If device synchronization is disabled,
/// no tokens exist or the chat is unpromoted, the function does nothing.
/// The caller should perform `SchedulerState::interrupt_smtp()` on its own to trigger sending.
pub(crate) async fn sync_qr_code_tokens(&self, chat_id: Option<ChatId>) -> Result<()> {
/// The caller should call `SchedulerState::interrupt_inbox()` on its own to trigger sending.
pub(crate) async fn sync_qr_code_tokens(&self, grpid: Option<&str>) -> Result<()> {
if !self.should_send_sync_msgs().await? {
return Ok(());
}
if let (Some(invitenumber), Some(auth)) = (
token::lookup(self, Namespace::InviteNumber, chat_id).await?,
token::lookup(self, Namespace::Auth, chat_id).await?,
token::lookup(self, Namespace::InviteNumber, grpid).await?,
token::lookup(self, Namespace::Auth, grpid).await?,
) {
let grpid = if let Some(chat_id) = chat_id {
let chat = Chat::load_from_db(self, chat_id).await?;
if !chat.is_promoted() {
info!(
self,
"group '{}' not yet promoted, do not sync tokens yet.", chat.grpid
);
return Ok(());
}
Some(chat.grpid)
} else {
None
};
self.add_sync_item(SyncData::AddQrToken(QrTokenData {
invitenumber,
auth,
grpid,
grpid: grpid.map(|s| s.to_string()),
}))
.await?;
}
@@ -167,7 +153,7 @@ impl Context {
grpid: None,
}))
.await?;
self.scheduler.interrupt_smtp().await;
self.scheduler.interrupt_inbox().await;
Ok(())
}
@@ -247,17 +233,6 @@ impl Context {
.body(json)
}
/// Deletes IDs as returned by `build_sync_json()`.
pub(crate) async fn delete_sync_ids(&self, ids: String) -> Result<()> {
self.sql
.execute(
&format!("DELETE FROM multi_device_sync WHERE id IN ({ids});"),
(),
)
.await?;
Ok(())
}
/// Takes a JSON string created by `build_sync_json()`
/// and construct `SyncItems` from it.
pub(crate) fn parse_sync_items(&self, serialized: String) -> Result<SyncItems> {
@@ -296,21 +271,9 @@ impl Context {
}
async fn add_qr_token(&self, token: &QrTokenData) -> Result<()> {
let chat_id = if let Some(grpid) = &token.grpid {
if let Some((chat_id, _, _)) = chat::get_chat_id_by_grpid(self, grpid).await? {
Some(chat_id)
} else {
warn!(
self,
"Ignoring token for nonexistent/deleted group '{}'.", grpid
);
return Ok(());
}
} else {
None
};
token::save(self, Namespace::InviteNumber, chat_id, &token.invitenumber).await?;
token::save(self, Namespace::Auth, chat_id, &token.auth).await?;
let grpid = token.grpid.as_deref();
token::save(self, Namespace::InviteNumber, grpid, &token.invitenumber).await?;
token::save(self, Namespace::Auth, grpid, &token.auth).await?;
Ok(())
}
@@ -328,11 +291,11 @@ mod tests {
use anyhow::bail;
use super::*;
use crate::chat::ProtectionStatus;
use crate::chat::{remove_contact_from_chat, Chat, ProtectionStatus};
use crate::chatlist::Chatlist;
use crate::contact::{Contact, Origin};
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{TestContext, TestContextManager};
use crate::test_utils::{self, TestContext, TestContextManager};
use crate::tools::SystemTime;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -410,7 +373,12 @@ mod tests {
);
assert!(t.build_sync_json().await?.is_some());
t.delete_sync_ids(ids).await?;
t.sql
.execute(
&format!("DELETE FROM multi_device_sync WHERE id IN ({ids})"),
(),
)
.await?;
assert!(t.build_sync_json().await?.is_none());
let sync_items = t.parse_sync_items(serialized)?;
@@ -591,7 +559,7 @@ mod tests {
// let alice's other device receive and execute the sync message,
// also here, self-talk should stay hidden
let sent_msg = alice.pop_sent_msg().await;
let sent_msg = alice.pop_sent_sync_msg().await;
let alice2 = TestContext::new_alice().await;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
alice2.recv_msg_trash(&sent_msg).await;
@@ -619,7 +587,7 @@ mod tests {
.set_config(Config::Displayname, Some("Alice Human"))
.await?;
alice.send_sync_msg().await?;
alice.pop_sent_msg().await;
alice.pop_sent_sync_msg().await;
let msg = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(msg.text, "hi");
@@ -634,22 +602,27 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unpromoted_group_no_qr_sync() -> Result<()> {
async fn test_unpromoted_group_qr_sync() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
let alice_chatid =
chat::create_group_chat(alice, ProtectionStatus::Protected, "the chat").await?;
let qr = get_securejoin_qr(alice, Some(alice_chatid)).await?;
let msg_id = alice.send_sync_msg().await?;
assert!(msg_id.is_none());
// alice2 syncs the QR code token.
let alice2 = &tcm.alice().await;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
test_utils::sync(alice, alice2).await;
let bob = &tcm.bob().await;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let msg_id = alice.send_sync_msg().await?;
// The group becomes promoted when Bob joins, so the QR code token is synced.
// Core <= v1.143 doesn't sync QR code tokens immediately, so current Core does that when a
// group is promoted for compatibility (because the group could be created by older Core).
// TODO: assert!(msg_id.is_none());
assert!(msg_id.is_some());
let sent = alice.pop_sent_msg().await;
let sent = alice.pop_sent_sync_msg().await;
let msg = alice.parse_msg(&sent).await;
let mut sync_items = msg.sync_items.unwrap().items;
assert_eq!(sync_items.len(), 1);
@@ -658,11 +631,22 @@ mod tests {
unreachable!();
};
// Remove Bob because alice2 doesn't have their key.
let alice_bob_id = alice.add_or_lookup_contact(bob).await.id;
remove_contact_from_chat(alice, alice_chatid, alice_bob_id).await?;
alice.pop_sent_msg().await;
let sent = alice
.send_text(alice_chatid, "Promoting group to another device")
.await;
alice2.recv_msg(&sent).await;
let fiona = &tcm.fiona().await;
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
let msg_id = alice.send_sync_msg().await?;
// The QR code token was already synced before.
assert!(msg_id.is_none());
tcm.exec_securejoin_qr(fiona, alice2, &qr).await;
let msg = fiona.get_last_msg().await;
assert_eq!(
msg.text,
"Member Me (fiona@example.net) added by alice@example.org."
);
Ok(())
}
}

View File

@@ -256,19 +256,28 @@ impl TestContextBuilder {
/// Builds the [`TestContext`].
pub async fn build(self) -> TestContext {
let name = self.key_pair.as_ref().map(|key| key.addr.local.clone());
let test_context = TestContext::new_internal(name, self.log_sink).await;
if let Some(key_pair) = self.key_pair {
test_context
.configure_addr(&key_pair.addr.to_string())
.await;
let userid = {
let public_key = &key_pair.public;
let id_bstr = public_key.details.users.first().unwrap().id.id();
String::from_utf8(id_bstr.to_vec()).unwrap()
};
let addr = mailparse::addrparse(&userid)
.unwrap()
.extract_single_info()
.unwrap()
.addr;
let name = EmailAddress::new(&addr).unwrap().local;
let test_context = TestContext::new_internal(Some(name), self.log_sink).await;
test_context.configure_addr(&addr).await;
key::store_self_keypair(&test_context, &key_pair, KeyPairUse::Default)
.await
.expect("Failed to save key");
test_context
} else {
TestContext::new_internal(None, self.log_sink).await
}
test_context
}
}
@@ -429,7 +438,7 @@ impl TestContext {
/// Retrieves a sent message from the jobs table.
///
/// This retrieves and removes a message which has been scheduled to send from the jobs
/// table. Messages are returned in the order they have been sent.
/// table. Messages are returned in the reverse order of sending.
///
/// Panics if there is no message or on any error.
pub async fn pop_sent_msg(&self) -> SentMessage<'_> {
@@ -474,9 +483,17 @@ impl TestContext {
.execute("DELETE FROM smtp WHERE id=?;", (rowid,))
.await
.expect("failed to remove job");
update_msg_state(&self.ctx, msg_id, MessageState::OutDelivered)
if !self
.ctx
.sql
.exists("SELECT COUNT(*) FROM smtp WHERE msg_id=?", (msg_id,))
.await
.expect("failed to update message state");
.expect("Failed to check for more jobs")
{
update_msg_state(&self.ctx, msg_id, MessageState::OutDelivered)
.await
.expect("failed to update message state");
}
let payload_headers = payload.split("\r\n\r\n").next().unwrap().lines();
let payload_header_names: Vec<_> = payload_headers
@@ -515,6 +532,46 @@ impl TestContext {
})
}
/// Retrieves a sent sync message from the db.
///
/// This retrieves and removes a sync message which has been scheduled to send from the jobs
/// table. Messages are returned in the order they have been sent.
///
/// Panics if there is no message or on any error.
pub async fn pop_sent_sync_msg(&self) -> SentMessage<'_> {
let (id, msg_id, payload) = self
.ctx
.sql
.query_row(
"SELECT id, msg_id, mime \
FROM imap_send \
ORDER BY id",
(),
|row| {
let rowid: i64 = row.get(0)?;
let msg_id: MsgId = row.get(1)?;
let mime: String = row.get(2)?;
Ok((rowid, msg_id, mime))
},
)
.await
.expect("query_row failed");
self.ctx
.sql
.execute("DELETE FROM imap_send WHERE id=?", (id,))
.await
.expect("failed to remove job");
update_msg_state(&self.ctx, msg_id, MessageState::OutDelivered)
.await
.expect("failed to update message state");
SentMessage {
payload,
sender_msg_id: msg_id,
sender_context: &self.ctx,
recipients: self.get_primary_self_addr().await.unwrap(),
}
}
/// Parses a message.
///
/// Parsing a message does not run the entire receive pipeline, but is not without
@@ -971,55 +1028,39 @@ impl SentMessage<'_> {
///
/// The keypair was created using the crate::key::tests::gen_key test.
pub fn alice_keypair() -> KeyPair {
let addr = EmailAddress::new("alice@example.org").unwrap();
let public = key::SignedPublicKey::from_asc(include_str!("../test-data/key/alice-public.asc"))
.unwrap()
.0;
let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/alice-secret.asc"))
.unwrap()
.0;
KeyPair {
addr,
public,
secret,
}
KeyPair { public, secret }
}
/// Load a pre-generated keypair for bob@example.net from disk.
///
/// Like [alice_keypair] but a different key and identity.
pub fn bob_keypair() -> KeyPair {
let addr = EmailAddress::new("bob@example.net").unwrap();
let public = key::SignedPublicKey::from_asc(include_str!("../test-data/key/bob-public.asc"))
.unwrap()
.0;
let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/bob-secret.asc"))
.unwrap()
.0;
KeyPair {
addr,
public,
secret,
}
KeyPair { public, secret }
}
/// Load a pre-generated keypair for fiona@example.net from disk.
///
/// Like [alice_keypair] but a different key and identity.
pub fn fiona_keypair() -> KeyPair {
let addr = EmailAddress::new("fiona@example.net").unwrap();
let public = key::SignedPublicKey::from_asc(include_str!("../test-data/key/fiona-public.asc"))
.unwrap()
.0;
let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/fiona-secret.asc"))
.unwrap()
.0;
KeyPair {
addr,
public,
secret,
}
KeyPair { public, secret }
}
/// Utility to help wait for and retrieve events.
@@ -1140,7 +1181,7 @@ pub(crate) async fn mark_as_verified(this: &TestContext, other: &TestContext) {
/// alice0's side that implies sending a sync message.
pub(crate) async fn sync(alice0: &TestContext, alice1: &TestContext) {
alice0.send_sync_msg().await.unwrap();
let sync_msg = alice0.pop_sent_msg().await;
let sync_msg = alice0.pop_sent_sync_msg().await;
let no_msg = alice1.recv_msg_opt(&sync_msg).await;
assert!(no_msg.is_none());
}

View File

@@ -7,7 +7,6 @@
use anyhow::Result;
use deltachat_derive::{FromSql, ToSql};
use crate::chat::ChatId;
use crate::context::Context;
use crate::tools::{create_id, time};
@@ -27,34 +26,22 @@ pub enum Namespace {
pub async fn save(
context: &Context,
namespace: Namespace,
foreign_id: Option<ChatId>,
foreign_key: Option<&str>,
token: &str,
) -> Result<()> {
match foreign_id {
Some(foreign_id) => context
.sql
.execute(
"INSERT INTO tokens (namespc, foreign_id, token, timestamp) VALUES (?, ?, ?, ?);",
(namespace, foreign_id, token, time()),
)
.await?,
None => {
context
.sql
.execute(
"INSERT INTO tokens (namespc, token, timestamp) VALUES (?, ?, ?);",
(namespace, token, time()),
)
.await?
}
};
context
.sql
.execute(
"INSERT INTO tokens (namespc, foreign_key, token, timestamp) VALUES (?, ?, ?, ?)",
(namespace, foreign_key.unwrap_or(""), token, time()),
)
.await?;
Ok(())
}
/// Lookup most recently created token for a namespace/chat combination.
/// Looks up most recently created token for a namespace / foreign key combination.
///
/// As there may be more than one valid token for a chat-id,
/// As there may be more than one such valid token,
/// (eg. when a qr code token is withdrawn, recreated and revived later),
/// use lookup() for qr-code creation only;
/// do not use lookup() to check for token validity.
@@ -63,43 +50,28 @@ pub async fn save(
pub async fn lookup(
context: &Context,
namespace: Namespace,
chat: Option<ChatId>,
foreign_key: Option<&str>,
) -> Result<Option<String>> {
let token = match chat {
Some(chat_id) => {
context
.sql
.query_get_value(
"SELECT token FROM tokens WHERE namespc=? AND foreign_id=? ORDER BY timestamp DESC LIMIT 1;",
(namespace, chat_id),
)
.await?
}
// foreign_id is declared as `INTEGER DEFAULT 0` in the schema.
None => {
context
.sql
.query_get_value(
"SELECT token FROM tokens WHERE namespc=? AND foreign_id=0 ORDER BY timestamp DESC LIMIT 1;",
(namespace,),
)
.await?
}
};
Ok(token)
context
.sql
.query_get_value(
"SELECT token FROM tokens WHERE namespc=? AND foreign_key=? ORDER BY timestamp DESC LIMIT 1",
(namespace, foreign_key.unwrap_or("")),
)
.await
}
pub async fn lookup_or_new(
context: &Context,
namespace: Namespace,
foreign_id: Option<ChatId>,
foreign_key: Option<&str>,
) -> Result<String> {
if let Some(token) = lookup(context, namespace, foreign_id).await? {
if let Some(token) = lookup(context, namespace, foreign_key).await? {
return Ok(token);
}
let token = create_id();
save(context, namespace, foreign_id, &token).await?;
save(context, namespace, foreign_key, &token).await?;
Ok(token)
}
@@ -114,23 +86,22 @@ pub async fn exists(context: &Context, namespace: Namespace, token: &str) -> Res
Ok(exists)
}
/// Looks up ChatId by auth token.
/// Looks up foreign key by auth token.
///
/// Returns None if auth token is not valid.
/// Returns zero/unset ChatId if the token corresponds to "setup contact" rather than group join.
pub async fn auth_chat_id(context: &Context, token: &str) -> Result<Option<ChatId>> {
let chat_id: Option<ChatId> = context
/// Returns an empty string if the token corresponds to "setup contact" rather than group join.
pub async fn auth_foreign_key(context: &Context, token: &str) -> Result<Option<String>> {
context
.sql
.query_row_optional(
"SELECT foreign_id FROM tokens WHERE namespc=? AND token=?",
"SELECT foreign_key FROM tokens WHERE namespc=? AND token=?",
(Namespace::Auth, token),
|row| {
let chat_id: ChatId = row.get(0)?;
Ok(chat_id)
let foreign_key: String = row.get(0)?;
Ok(foreign_key)
},
)
.await?;
Ok(chat_id)
.await
}
pub async fn delete(context: &Context, namespace: Namespace, token: &str) -> Result<()> {

View File

@@ -36,7 +36,8 @@ use tokio::{fs, io};
use url::Url;
use crate::chat::{add_device_msg, add_device_msg_with_importance};
use crate::constants::{DC_ELLIPSIS, DC_OUTDATED_WARNING_DAYS};
use crate::config::Config;
use crate::constants::{self, DC_ELLIPSIS, DC_OUTDATED_WARNING_DAYS};
use crate::context::Context;
use crate::events::EventType;
use crate::message::{Message, Viewtype};
@@ -54,7 +55,7 @@ pub(crate) fn truncate(buf: &str, approx_chars: usize) -> Cow<str> {
.map(|(n, _)| n)
.unwrap_or_default();
if let Some(index) = buf[..end_pos].rfind(|c| c == ' ' || c == '\n') {
if let Some(index) = buf[..end_pos].rfind([' ', '\n']) {
Cow::Owned(format!("{}{}", &buf[..=index], DC_ELLIPSIS))
} else {
Cow::Owned(format!("{}{}", &buf[..end_pos], DC_ELLIPSIS))
@@ -98,7 +99,7 @@ pub(crate) fn truncate_by_lines(
// Text has too many lines and needs to be truncated.
let text = {
if let Some(buffer) = buf.get(..end_pos) {
if let Some(index) = buffer.rfind(|c| c == ' ' || c == '\n') {
if let Some(index) = buffer.rfind([' ', '\n']) {
buf.get(..=index)
} else {
buf.get(..end_pos)
@@ -123,6 +124,22 @@ pub(crate) fn truncate_by_lines(
}
}
/// Shortens a message text if necessary according to the configuration. Adds "[...]" to the end of
/// the shortened text.
///
/// Returns the resulting text and a bool telling whether a truncation was done.
pub(crate) async fn truncate_msg_text(context: &Context, text: String) -> Result<(String, bool)> {
if context.get_config_bool(Config::Bot).await? {
return Ok((text, false));
}
// Truncate text if it has too many lines
Ok(truncate_by_lines(
text,
constants::DC_DESIRED_TEXT_LINES,
constants::DC_DESIRED_TEXT_LINE_LEN,
))
}
/* ******************************************************************************
* date/time tools
******************************************************************************/
@@ -255,30 +272,26 @@ async fn maybe_warn_on_outdated(context: &Context, now: i64, approx_compile_time
/* Message-ID tools */
/// Generate an ID. The generated ID should be as short and as unique as possible:
/// - short, because it may also used as part of Message-ID headers or in QR codes
/// - unique as two IDs generated on two devices should not be the same. However, collisions are not world-wide but only by the few contacts.
/// Generate an unique ID.
///
/// IDs generated by this function are 66 bit wide and are returned as 11 base64 characters.
/// The generated ID should be short but unique:
/// - short, because it used in Message-ID and Chat-Group-ID headers and in QR codes
/// - unique as two IDs generated on two devices should not be the same
///
/// Additional information when used as a message-id or group-id:
/// - for OUTGOING messages this ID is written to the header as `Chat-Group-ID:` and is added to the message ID as `Gr.<grpid>.<random>@<random>`
/// - for INCOMING messages, the ID is taken from the Chat-Group-ID-header or from the Message-ID in the In-Reply-To: or References:-Header
/// - the group-id should be a string with the characters [a-zA-Z0-9\-_]
/// IDs generated by this function have 144 bits of entropy
/// and are returned as 24 Base64 characters, each containing 6 bits of entropy.
/// 144 is chosen because it is sufficiently secure
/// (larger than AES-128 keys used for message encryption)
/// and divides both by 8 (byte size) and 6 (number of bits in a single Base64 character).
pub(crate) fn create_id() -> String {
// ThreadRng implements CryptoRng trait and is supposed to be cryptographically secure.
let mut rng = thread_rng();
// Generate 72 random bits.
let mut arr = [0u8; 9];
// Generate 144 random bits.
let mut arr = [0u8; 18];
rng.fill(&mut arr[..]);
// Take 11 base64 characters containing 66 random bits.
base64::engine::general_purpose::URL_SAFE
.encode(arr)
.chars()
.take(11)
.collect()
base64::engine::general_purpose::URL_SAFE.encode(arr)
}
/// Returns true if given string is a valid ID.
@@ -294,7 +307,7 @@ pub(crate) fn validate_id(s: &str) -> bool {
/// - the message ID should be globally unique
/// - do not add a counter or any private data as this leaks information unnecessarily
pub(crate) fn create_outgoing_rfc724_mid() -> String {
format!("Mr.{}.{}@localhost", create_id(), create_id())
format!("{}@localhost", create_id())
}
// the returned suffix is lower-case
@@ -908,7 +921,7 @@ DKIM Results: Passed=true";
#[test]
fn test_create_id() {
let buf = create_id();
assert_eq!(buf.len(), 11);
assert_eq!(buf.len(), 24);
}
#[test]
@@ -944,7 +957,7 @@ DKIM Results: Passed=true";
#[test]
fn test_create_outgoing_rfc724_mid() {
let mid = create_outgoing_rfc724_mid();
assert!(mid.starts_with("Mr."));
assert_eq!(mid.len(), 34);
assert!(mid.ends_with("@localhost"));
}

View File

@@ -23,13 +23,13 @@ use std::path::Path;
use anyhow::{anyhow, bail, ensure, format_err, Context as _, Result};
use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader;
use deltachat_contact_tools::sanitize_bidi_characters;
use deltachat_derive::FromSql;
use lettre_email::PartBuilder;
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::AsyncReadExt;
use crate::chat::{self, Chat};
use crate::constants::Chattype;
@@ -195,7 +195,7 @@ fn find_zip_entry<'a>(
name: &str,
) -> Option<(usize, &'a async_zip::StoredZipEntry)> {
for (i, ent) in file.entries().iter().enumerate() {
if ent.entry().filename() == name {
if ent.filename().as_bytes() == name.as_bytes() {
return Some((i, ent));
}
}
@@ -212,7 +212,7 @@ impl Context {
return Ok(false);
}
let archive = match async_zip::read::mem::ZipFileReader::new(file.to_vec()).await {
let archive = match async_zip::base::read::mem::ZipFileReader::new(file.to_vec()).await {
Ok(archive) => archive,
Err(_) => {
info!(self, "{} cannot be opened as zip-file", &filename);
@@ -235,7 +235,7 @@ impl Context {
bail!("{} is not a valid webxdc file", filename);
}
let valid = match async_zip::read::fs::ZipFileReader::new(path).await {
let valid = match FsZipFileReader::new(path).await {
Ok(archive) => {
if find_zip_entry(archive.file(), "index.html").is_none() {
warn!(self, "{} misses index.html", filename);
@@ -791,27 +791,24 @@ fn parse_webxdc_manifest(bytes: &[u8]) -> Result<WebxdcManifest> {
Ok(manifest)
}
async fn get_blob(archive: &async_zip::read::fs::ZipFileReader, name: &str) -> Result<Vec<u8>> {
async fn get_blob(archive: &FsZipFileReader, name: &str) -> Result<Vec<u8>> {
let (i, _) = find_zip_entry(archive.file(), name)
.ok_or_else(|| anyhow!("no entry found for {}", name))?;
let mut reader = archive.entry(i).await?;
let mut reader = archive.reader_with_entry(i).await?;
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await?;
reader.read_to_end_checked(&mut buf).await?;
Ok(buf)
}
impl Message {
/// Get handle to a webxdc ZIP-archive.
/// To check for file existence use archive.by_name(), to read a file, use get_blob(archive).
async fn get_webxdc_archive(
&self,
context: &Context,
) -> Result<async_zip::read::fs::ZipFileReader> {
async fn get_webxdc_archive(&self, context: &Context) -> Result<FsZipFileReader> {
let path = self
.get_file(context)
.ok_or_else(|| format_err!("No webxdc instance file."))?;
let path_abs = get_abs_path(context, &path);
let archive = async_zip::read::fs::ZipFileReader::new(path_abs).await?;
let archive = FsZipFileReader::new(path_abs).await?;
Ok(archive)
}