mirror of
https://github.com/chatmail/core.git
synced 2026-04-05 23:22:11 +03:00
Compare commits
1 Commits
shadowsock
...
iequidoo/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11db74dcf |
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,35 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report something that isn't working.
|
||||
title: ''
|
||||
assignees: ''
|
||||
labels: bug
|
||||
---
|
||||
|
||||
<!--
|
||||
This is the chatmail core's bug report tracker.
|
||||
For Delta Chat feature requests and support, please go to the forum: https://support.delta.chat
|
||||
Please fill out as much of this form as you can (leaving out stuff that is not applicable is ok).
|
||||
-->
|
||||
|
||||
- Operating System (Linux/Mac/Windows/iOS/Android):
|
||||
- Core Version:
|
||||
- Client Version:
|
||||
|
||||
## Expected behavior
|
||||
|
||||
*What did you try to achieve?*
|
||||
|
||||
## Actual behavior
|
||||
|
||||
*What happened instead?*
|
||||
|
||||
### Steps to reproduce the problem
|
||||
|
||||
1.
|
||||
2.
|
||||
|
||||
### Screenshots
|
||||
|
||||
### Logs
|
||||
|
||||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@@ -20,22 +20,20 @@ permissions: {}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_VERSION: 1.86.0
|
||||
|
||||
# Minimum Supported Rust Version
|
||||
MSRV: 1.82.0
|
||||
|
||||
jobs:
|
||||
lint_rust:
|
||||
name: Lint Rust
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: 1.84.1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- name: Install rustfmt and clippy
|
||||
run: rustup toolchain install $RUST_VERSION --profile minimal --component rustfmt --component clippy
|
||||
run: rustup toolchain install $RUSTUP_TOOLCHAIN --profile minimal --component rustfmt --component clippy
|
||||
- name: Cache rust cargo artifacts
|
||||
uses: swatinem/rust-cache@v2
|
||||
- name: Run rustfmt
|
||||
@@ -93,36 +91,25 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
rust: latest
|
||||
rust: 1.84.1
|
||||
- os: windows-latest
|
||||
rust: latest
|
||||
rust: 1.84.1
|
||||
- os: macos-latest
|
||||
rust: latest
|
||||
rust: 1.84.1
|
||||
|
||||
# Minimum Supported Rust Version
|
||||
# Minimum Supported Rust Version = 1.81.0
|
||||
- os: ubuntu-latest
|
||||
rust: minimum
|
||||
rust: 1.81.0
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- run:
|
||||
echo "RUSTUP_TOOLCHAIN=$MSRV" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
if: matrix.rust == 'minimum'
|
||||
- run:
|
||||
echo "RUSTUP_TOOLCHAIN=$RUST_VERSION" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
if: matrix.rust == 'latest'
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust ${{ matrix.rust }}
|
||||
run: rustup toolchain install --profile minimal $RUSTUP_TOOLCHAIN
|
||||
shell: bash
|
||||
- run: rustup override set $RUSTUP_TOOLCHAIN
|
||||
shell: bash
|
||||
run: rustup toolchain install --profile minimal ${{ matrix.rust }}
|
||||
- run: rustup override set ${{ matrix.rust }}
|
||||
|
||||
- name: Cache rust cargo artifacts
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
@@ -9,7 +9,7 @@ permissions: {}
|
||||
jobs:
|
||||
pack-module:
|
||||
name: "Publish @deltachat/jsonrpc-client"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
3
.github/workflows/jsonrpc.yml
vendored
3
.github/workflows/jsonrpc.yml
vendored
@@ -37,6 +37,9 @@ jobs:
|
||||
run: npm run test
|
||||
env:
|
||||
CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }}
|
||||
- name: make sure websocket server version still builds
|
||||
working-directory: deltachat-jsonrpc
|
||||
run: cargo build --bin deltachat-jsonrpc-server --features webserver
|
||||
- name: Run linter
|
||||
working-directory: deltachat-jsonrpc/typescript
|
||||
run: npm run prettier:check
|
||||
|
||||
269
CHANGELOG.md
269
CHANGELOG.md
@@ -1,267 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.159.3] - 2025-04-24
|
||||
|
||||
### CI
|
||||
|
||||
- Use `ubuntu-latest` runner for `@deltachat/jsonrpc-client` publishing.
|
||||
|
||||
## [1.159.2] - 2025-04-23
|
||||
|
||||
### Fixes
|
||||
|
||||
- Allow to send to chats after failed securejoin again ([#6817](https://github.com/chatmail/core/pull/6817)).
|
||||
- Parse login scheme in `add_transport_from_qr()` ([#6802](https://github.com/chatmail/core/pull/6802)).
|
||||
- Lowercase address in add_transport() ([#6805](https://github.com/chatmail/core/pull/6805)).
|
||||
|
||||
### API-Changes
|
||||
|
||||
- Rename add_transport() -> add_or_update_transport() ([#6800](https://github.com/chatmail/core/pull/6800)).
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update yerpc to 0.6.4.
|
||||
- Clean up `deltachat-jsonrpc` dependencies.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Move logins into SQL table ([#6724](https://github.com/chatmail/core/pull/6724)).
|
||||
|
||||
### Tests
|
||||
|
||||
- Check headers absense straightforwardly.
|
||||
- Fix mismatch between the contact and the account in securejoin tests.
|
||||
- Test that key of the recipient is gossiped in 1:1 chats.
|
||||
|
||||
## [1.159.1] - 2025-04-12
|
||||
|
||||
### API-Changes
|
||||
|
||||
- deltachat-rpc-client: Add `Account.add_transport()`.
|
||||
- Add jsonrpc for info_contact_id.
|
||||
|
||||
### Build system
|
||||
|
||||
- Update crossbeam-channel from 0.5.14 to 0.5.15.
|
||||
- Increase MSRV to 1.82.0.
|
||||
|
||||
### CI
|
||||
|
||||
- Don't make ruff format quiet ([#6785](https://github.com/chatmail/core/pull/6785)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- MimeFactory.member_timestamps has the same order as To: rather than RCPT TO:.
|
||||
- Two JsonRPC doc improvements ([#6778](https://github.com/chatmail/core/pull/6778)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Improve error message when the user tries to do AEAP ([#6786](https://github.com/chatmail/core/pull/6786)).
|
||||
- Pass email and password via env in python-jsonrpc.
|
||||
- Track gossiping per (chat, fingerprint) pair.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Add missing ChatDeleted event to python jsonrpc client.
|
||||
- Never send Autocrypt-Gossip in broadcast lists.
|
||||
- Restart I/O when mvbox_move setting is changed.
|
||||
|
||||
### Tests
|
||||
|
||||
- Port test_delete_deltachat_folder to JSON-RPC.
|
||||
- Autocrypt-Gossip header isn't sent in broadcast messages.
|
||||
- Encrypt test_subject_in_group().
|
||||
- Encrypt test_remove_member_bcc.
|
||||
|
||||
## [1.159.0] - 2025-04-08
|
||||
|
||||
### API-Changes
|
||||
|
||||
- deltachat-rpc-client: Add Message.get_info().
|
||||
- CFFI: Add `dc_make_vcard()` and `dc_import_vcard()`.
|
||||
- Add legacy Python bindings for `make_vcard` and `import_vcard`.
|
||||
|
||||
### CI
|
||||
|
||||
- Upgrade Rust from 1.84.1 to 1.86.0 ([#6784](https://github.com/chatmail/core/pull/6784)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Add name resp. "Me" to contact encryption info ([#6720](https://github.com/chatmail/core/pull/6720)).
|
||||
- Get contact-id for info messages ([#6714](https://github.com/chatmail/core/pull/6714)).
|
||||
- No unencrypted chat when securejoin times out ([#6722](https://github.com/chatmail/core/pull/6722)).
|
||||
- Clear `Param::IsEdited` when forwarding a message.
|
||||
- Remove email address from 'add second device' qr code ([#6760](https://github.com/chatmail/core/pull/6760)).
|
||||
- Parse Proton Mail vCards again ([#6771](https://github.com/chatmail/core/pull/6771)).
|
||||
- Do not consider encrypting to the primary OpenPGP key.
|
||||
|
||||
### Fixes
|
||||
|
||||
- jsonrpc: Fix deadlock in get_all_accounts().
|
||||
- Set GroupNameTimestamp on group promotion ([#6729](https://github.com/chatmail/core/pull/6729)).
|
||||
- Encrypt broadcast lists.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update yerpc to 0.6.3.
|
||||
- cargo: Update textwrap from 0.16.1 to 0.16.2.
|
||||
- cargo: Bump uuid from 1.15.1 to 1.16.0.
|
||||
- cargo: Bump libc from 0.2.170 to 0.2.171.
|
||||
- cargo: Bump anyhow from 1.0.96 to 1.0.97.
|
||||
- cargo: Bump bytes from 1.10.0 to 1.10.1.
|
||||
- cargo: Bump once_cell from 1.20.3 to 1.21.3.
|
||||
- cargo: Bump thiserror from 2.0.11 to 2.0.12.
|
||||
- cargo: Bump pin-project from 1.1.9 to 1.1.10.
|
||||
- cargo: Bump hyper-util from 0.1.10 to 0.1.11.
|
||||
- cargo: Bump log from 0.4.26 to 0.4.27.
|
||||
- cargo: Bump tokio-util from 0.7.13 to 0.7.14.
|
||||
- cargo: Bump syn from 2.0.98 to 2.0.100.
|
||||
- cargo: Bump serde_json from 1.0.139 to 1.0.140.
|
||||
- cargo: Bump quote from 1.0.38 to 1.0.40.
|
||||
- cargo: Bump http-body-util from 0.1.2 to 0.1.3.
|
||||
- cargo: Bump openssl from 0.10.71 to 0.10.72.
|
||||
- cargo: Bump quick-xml from 0.37.2 to 0.37.4.
|
||||
- cargo: Bump blake3 from 1.6.1 to 1.8.0.
|
||||
- cargo: Bump tokio from 1.43.0 to 1.43.1 ([#6780](https://github.com/chatmail/core/pull/6780)).
|
||||
- Add issue template.
|
||||
- Add bug label on bug issue template.
|
||||
- cargo: Bump tokio from 1.43.0 to 1.44.1.
|
||||
- cargo: Bump fd-lock from 4.0.2 to 4.0.4.
|
||||
- Update async-smtp from 0.10.0 to 0.10.1.
|
||||
- Update async-imap from 0.10.3 to 0.10.4.
|
||||
- cargo: Bump tempfile from 3.14.0 to 3.19.1.
|
||||
- cargo: Bump image from 0.25.5 to 0.25.6.
|
||||
- cargo: Bump serde from 1.0.218 to 1.0.219.
|
||||
|
||||
### Other
|
||||
|
||||
- Add python and tox to flake.nix devshell ([#6233](https://github.com/chatmail/core/pull/6233))
|
||||
- Update spec wrt edit/delete, minor rewordings ([#6708](https://github.com/chatmail/core/pull/6708))
|
||||
- Update 'takes longer' fallback wording.
|
||||
- Handle classic emails as such only in classic profiles ([#6767](https://github.com/chatmail/core/pull/6767))
|
||||
- Move ASM strings to core, point to "Add Second Device" ([#6777](https://github.com/chatmail/core/pull/6777))
|
||||
|
||||
### Refactor
|
||||
|
||||
- Replace `once_cell::sync::Lazy` with `std::sync::LazyLock`.
|
||||
- Move vCard code to its own file ([#6776](https://github.com/chatmail/core/pull/6776)).
|
||||
|
||||
### Tests
|
||||
|
||||
- Use encryption in more Rust tests.
|
||||
- Use encryption in all JSON-RPC online tests.
|
||||
- Encrypt legacy Python tests.
|
||||
- Send only encrypted messages in online JS tests.
|
||||
- Add APIs to create `dom@example.net` and `elena@example.net`.
|
||||
- Split public keys from secret keys in runtime.
|
||||
- Remove fetch_existing tests.
|
||||
- Port test_forward_encrypted_to_unencrypted from legacy Python to Rust.
|
||||
- Port test_one_account_send_bcc_setting from legacy Python to JSON-RPC.
|
||||
- Port test_multidevice_sync_seen to JSON-RPC.
|
||||
- Use QR codes to setup contact with test bots.
|
||||
- Remove flaky key::tests::test_load_self_existing test ([#6763](https://github.com/chatmail/core/pull/6763)).
|
||||
- Update blob hash in blob::blob_tests::test_selfavatar_outside_blobdir.
|
||||
|
||||
## [1.158.0] - 2025-03-29
|
||||
|
||||
### API-Changes
|
||||
|
||||
- deltachat-rpc-client: Accept `Account` as `Account.create_contact()` argument.
|
||||
- Rust: Add `ContactId.set_name()`.
|
||||
- JSON-RPC: Rename parameter name in `get_webxdc_href` to `info_msg_id` to reduce confusion potential ([#6681](https://github.com/chatmail/core/pull/6681)).
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Nicer configuration error ([#6684](https://github.com/chatmail/core/pull/6684)).
|
||||
- securejoin: Do not create 1:1 chat on Alice's side until `vc-request-with-auth`.
|
||||
- Understandable error message when accounts.lock can't be locked ([#6695](https://github.com/chatmail/core/pull/6695)).
|
||||
- Simplify e2ee decision logic, remove majority vote.
|
||||
- Stop saving txt_raw.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Do not fail to send the message if some keys are missing.
|
||||
- Synchronize contact name changes.
|
||||
- Move group name timestamp update up in create_send_msg_jobs().
|
||||
- Fixes for transport JSON-RPC ([#6680](https://github.com/chatmail/core/pull/6680)).
|
||||
|
||||
### Build system
|
||||
|
||||
- deltachat-rpc-client: Move development dependencies from tox.ini to pyproject.toml.
|
||||
- Update resolve-conf from 0.7.0 to 0.7.1.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Do not convert SQL arguments to `String` unnecessarily.
|
||||
- Factor out `update_chat_names()`.
|
||||
- Use `created_timestamp()` instead of duplicating its code ([#6692](https://github.com/chatmail/core/pull/6692)).
|
||||
- Use `chat_id.get_timestamp()` instead of duplicating its code ([#6691](https://github.com/chatmail/core/pull/6691)).
|
||||
- Move `mark_recipients_as_verified()` call out of `has_verified_encryption()`.
|
||||
- Move `proxy_config` out of `ConfiguredLoginParam` ([#6712](https://github.com/chatmail/core/pull/6712)).
|
||||
|
||||
### Tests
|
||||
|
||||
- Use vCard in TestContext.add_or_lookup_contact().
|
||||
- Remove test_group_with_removed_message_id.
|
||||
- Use add_or_lookup_email_contact() in get_chat().
|
||||
- Use add_or_lookup_email_contact in test_setup_contact_ex.
|
||||
- Use vCards more in Python tests.
|
||||
- Use TestContextManager in more tests.
|
||||
- Use vCards to create contacts in more Rust tests.
|
||||
- Set chat name multiple times in a row.
|
||||
- Online test for renaming the group multiple times.
|
||||
|
||||
## [1.157.3] - 2025-03-19
|
||||
|
||||
### API-Changes
|
||||
|
||||
- jsonrpc: Add `copy_to_blob_dir` api ([#6660](https://github.com/chatmail/core/pull/6660)).
|
||||
- Add "delete_for_all" function in json-rpc ([#6672](https://github.com/chatmail/core/pull/6672)).
|
||||
- Sketch add_transport_from_qr(), add_transport(), list_transports(), delete_transport() APIs ([#6589](https://github.com/chatmail/core/pull/6589)).
|
||||
|
||||
### Build system
|
||||
|
||||
- Remove websocket support from deltachat-jsonrpc.
|
||||
- Remove encoded-words dependency.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Never send empty `To:` header ([#6663](https://github.com/chatmail/core/pull/6663)).
|
||||
- Use protected `Date` header for signed messages.
|
||||
- Fix setting up a profile and immediately transferring to a second device ([#6657](https://github.com/chatmail/core/pull/6657)).
|
||||
- Don't SMTP-send self-only messages if DeleteServerAfter is "immediate" ([#6661](https://github.com/chatmail/core/pull/6661)).
|
||||
- Use protected `Date` with protected Autocrypt.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: Bump uuid from 1.12.1 to 1.15.1.
|
||||
- Update `strum` dependency.
|
||||
|
||||
### Refactor
|
||||
|
||||
- deltachat-rpc-client: Use wait_for_event() type argument.
|
||||
|
||||
### Tests
|
||||
|
||||
- Avoid creating contacts in `test_sync_{accept,block}_before_first_msg()`.
|
||||
- Fix `test_no_old_msg_is_fresh` flakiness.
|
||||
|
||||
## [1.157.2] - 2025-03-15
|
||||
|
||||
### Fixes
|
||||
|
||||
- Prefer hidden Message-ID header if any.
|
||||
- Update async-compression to 0.4.21 to fix IMAP COMPRESS getting stuck.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Extract handle_edit_delete() function for message edit/delete ([#6664](https://github.com/chatmail/core/pull/6664)).
|
||||
|
||||
### Tests
|
||||
|
||||
- test_secure_join: Bob should not create a 1:1 chat before sending a message.
|
||||
- Return chat ID from TestContext.exec_securejoin_qr().
|
||||
|
||||
## [1.157.1] - 2025-03-13
|
||||
|
||||
### Miscellaneous Tasks
|
||||
@@ -6260,10 +5998,3 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[1.156.3]: https://github.com/chatmail/core/compare/v1.156.2..v1.156.3
|
||||
[1.157.0]: https://github.com/chatmail/core/compare/v1.156.3..v1.157.0
|
||||
[1.157.1]: https://github.com/chatmail/core/compare/v1.157.0..v1.157.1
|
||||
[1.157.2]: https://github.com/chatmail/core/compare/v1.157.1..v1.157.2
|
||||
[1.157.3]: https://github.com/chatmail/core/compare/v1.157.2..v1.157.3
|
||||
[1.158.0]: https://github.com/chatmail/core/compare/v1.157.3..v1.158.0
|
||||
[1.159.0]: https://github.com/chatmail/core/compare/v1.158.0..v1.159.0
|
||||
[1.159.1]: https://github.com/chatmail/core/compare/v1.159.0..v1.159.1
|
||||
[1.159.2]: https://github.com/chatmail/core/compare/v1.159.1..v1.159.2
|
||||
[1.159.3]: https://github.com/chatmail/core/compare/v1.159.2..v1.159.3
|
||||
|
||||
808
Cargo.lock
generated
808
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
39
Cargo.toml
39
Cargo.toml
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "1.159.3"
|
||||
version = "1.157.1"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.82"
|
||||
rust-version = "1.81"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
[profile.dev]
|
||||
@@ -41,15 +41,16 @@ ratelimit = { path = "./deltachat-ratelimit" }
|
||||
anyhow = { workspace = true }
|
||||
async-broadcast = "0.7.2"
|
||||
async-channel = { workspace = true }
|
||||
async-imap = { version = "0.10.4", default-features = false, features = ["runtime-tokio", "compress"] }
|
||||
async-imap = { version = "0.10.3", default-features = false, features = ["runtime-tokio", "compress"] }
|
||||
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
|
||||
async-smtp = { version = "0.10", default-features = false, features = ["runtime-tokio"] }
|
||||
async_zip = { version = "0.0.17", default-features = false, features = ["deflate", "tokio-fs"] }
|
||||
base64 = { workspace = true }
|
||||
brotli = { version = "8", default-features=false, features = ["std"] }
|
||||
brotli = { version = "7", default-features=false, features = ["std"] }
|
||||
bytes = "1"
|
||||
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
|
||||
data-encoding = "2.9.0"
|
||||
data-encoding = "2.7.0"
|
||||
encoded-words = "0.2"
|
||||
escaper = "0.1"
|
||||
fast-socks5 = "0.10"
|
||||
fd-lock = "4"
|
||||
@@ -57,11 +58,11 @@ futures-lite = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
hex = "0.4.0"
|
||||
hickory-resolver = "=0.25.0-alpha.5"
|
||||
http-body-util = "0.1.3"
|
||||
http-body-util = "0.1.2"
|
||||
humansize = "2"
|
||||
hyper = "1"
|
||||
hyper-util = "0.1.11"
|
||||
image = { version = "0.25.6", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
|
||||
hyper-util = "0.1.10"
|
||||
image = { version = "0.25.5", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
|
||||
iroh-gossip = { version = "0.33", default-features = false, features = ["net"] }
|
||||
iroh = { version = "0.33", default-features = false }
|
||||
kamadak-exif = "0.6.1"
|
||||
@@ -72,6 +73,7 @@ mime = "0.3.17"
|
||||
num_cpus = "1.16"
|
||||
num-derive = "0.4"
|
||||
num-traits = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
parking_lot = "0.12"
|
||||
percent-encoding = "2.3"
|
||||
pgp = { version = "0.15.0", default-features = false }
|
||||
@@ -91,12 +93,12 @@ serde_urlencoded = "0.7.1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha-1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
|
||||
smallvec = "1.15.0"
|
||||
strum = "0.27"
|
||||
strum_macros = "0.27"
|
||||
shadowsocks = { version = "1.22.0", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
|
||||
smallvec = "1.14.0"
|
||||
strum = "0.26"
|
||||
strum_macros = "0.26"
|
||||
tagger = "4.3.4"
|
||||
textwrap = "0.16.2"
|
||||
textwrap = "0.16.1"
|
||||
thiserror = { workspace = true }
|
||||
tokio-io-timeout = "1.2.0"
|
||||
tokio-rustls = { version = "0.26.2", default-features = false }
|
||||
@@ -108,7 +110,7 @@ toml = "0.8"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
webpki-roots = "0.26.8"
|
||||
blake3 = "1.8.2"
|
||||
blake3 = "1.6.1"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
|
||||
@@ -173,7 +175,7 @@ harness = false
|
||||
anyhow = "1"
|
||||
async-channel = "2.3.1"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4.41", default-features = false }
|
||||
chrono = { version = "0.4.40", default-features = false }
|
||||
deltachat-contact-tools = { path = "deltachat-contact-tools" }
|
||||
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
|
||||
deltachat = { path = ".", default-features = false }
|
||||
@@ -184,18 +186,19 @@ log = "0.4"
|
||||
mailparse = "0.16.1"
|
||||
nu-ansi-term = "0.46"
|
||||
num-traits = "0.2"
|
||||
once_cell = "1.20.2"
|
||||
rand = "0.8"
|
||||
regex = "1.10"
|
||||
rusqlite = "0.32"
|
||||
sanitize-filename = "0.5"
|
||||
serde = "1.0"
|
||||
serde_json = "1"
|
||||
tempfile = "3.19.1"
|
||||
tempfile = "3.14.0"
|
||||
thiserror = "2"
|
||||
tokio = "1"
|
||||
tokio-util = "0.7.14"
|
||||
tokio-util = "0.7.13"
|
||||
tracing-subscriber = "0.3"
|
||||
yerpc = "0.6.4"
|
||||
yerpc = "0.6.2"
|
||||
|
||||
[features]
|
||||
default = ["vendored"]
|
||||
|
||||
53
README.md
53
README.md
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img alt="Chatmail logo" src="https://github.com/user-attachments/assets/25742da7-a837-48cd-a503-b303af55f10d" width="300" style="float:middle;" />
|
||||
<img alt="Delta Chat Logo" height="200px" src="https://raw.githubusercontent.com/deltachat/deltachat-pages/master/assets/blog/rust-delta.png">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -11,31 +11,9 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
The chatmail core library implements low-level network and encryption protocols,
|
||||
integrated by many chat bots and higher level applications,
|
||||
allowing to securely participate in the globally scaled e-mail server network.
|
||||
We provide reproducibly-built `deltachat-rpc-server` static binaries
|
||||
that offer a stdio-based high-level JSON-RPC API for instant messaging purposes.
|
||||
|
||||
The following protocols are handled without requiring API users to know much about them:
|
||||
|
||||
- secure TLS setup with DNS caching and shadowsocks/proxy support
|
||||
|
||||
- robust [SMTP](https://github.com/chatmail/async-imap)
|
||||
and [IMAP](https://github.com/chatmail/async-smtp) handling
|
||||
|
||||
- safe and interoperable [MIME parsing](https://github.com/staktrace/mailparse)
|
||||
and [MIME building](https://github.com/stalwartlabs/mail-builder).
|
||||
|
||||
- security-audited end-to-end encryption with [rPGP](https://github.com/rpgp/rpgp)
|
||||
and [Autocrypt and SecureJoin protocols](https://securejoin.rtfd.io)
|
||||
|
||||
- ephemeral [Peer-to-Peer networking using Iroh](https://iroh.computer) for multi-device setup and
|
||||
[webxdc realtime data](https://delta.chat/en/2024-11-20-webxdc-realtime).
|
||||
|
||||
- a simulation- and real-world tested [P2P group membership
|
||||
protocol without requiring server state](https://github.com/chatmail/models/tree/main/group-membership).
|
||||
|
||||
<p align="center">
|
||||
The core library for Delta Chat, written in Rust
|
||||
</p>
|
||||
|
||||
## Installing Rust and Cargo
|
||||
|
||||
@@ -49,12 +27,12 @@ $ curl https://sh.rustup.rs -sSf | sh
|
||||
|
||||
## Using the CLI client
|
||||
|
||||
Compile and run the command line utility, using `cargo`:
|
||||
Compile and run Delta Chat Core command line utility, using `cargo`:
|
||||
|
||||
```
|
||||
$ cargo run --locked -p deltachat-repl -- ~/profile-db
|
||||
$ cargo run --locked -p deltachat-repl -- ~/deltachat-db
|
||||
```
|
||||
where ~/profile-db is the database file. The utility will create it if it does not exist.
|
||||
where ~/deltachat-db is the database file. Delta Chat will create it if it does not exist.
|
||||
|
||||
Optionally, install `deltachat-repl` binary with
|
||||
```
|
||||
@@ -62,13 +40,13 @@ $ cargo install --locked --path deltachat-repl/
|
||||
```
|
||||
and run as
|
||||
```
|
||||
$ deltachat-repl ~/profile-db
|
||||
$ deltachat-repl ~/deltachat-db
|
||||
```
|
||||
|
||||
Configure your account (if not already configured):
|
||||
|
||||
```
|
||||
Chatmail is awaiting your commands.
|
||||
Delta Chat Core is awaiting your commands.
|
||||
> set addr your@email.org
|
||||
> set mail_pw yourpassword
|
||||
> configure
|
||||
@@ -106,6 +84,11 @@ Single#10: yourfriends@email.org [yourfriends@email.org]
|
||||
Message sent.
|
||||
```
|
||||
|
||||
If `yourfriend@email.org` uses DeltaChat, but does not receive message just
|
||||
sent, it is advisable to check `Spam` folder. It is known that at least
|
||||
`gmx.com` treat such test messages as spam, unless told otherwise with web
|
||||
interface.
|
||||
|
||||
List messages when inside a chat:
|
||||
|
||||
```
|
||||
@@ -182,9 +165,11 @@ $ cargo bolero test fuzz_format_flowed --release=false -e afl -s NONE
|
||||
## Update Provider Data
|
||||
|
||||
To add the updates from the
|
||||
[provider-db](https://github.com/chatmail/provider-db) to the core,
|
||||
check line `REV=` inside `./scripts/update-provider-database.sh`
|
||||
and then run the script.
|
||||
[provider-db](https://github.com/deltachat/provider-db) to the core, run:
|
||||
|
||||
```
|
||||
./src/provider/update.py ../provider-db/_providers/ > src/provider/data.rs
|
||||
```
|
||||
|
||||
## Language bindings and frontend projects
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ license = "MPL-2.0"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
rusqlite = { workspace = true } # Needed in order to `impl rusqlite::types::ToSql for EmailAddress`. Could easily be put behind a feature.
|
||||
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
|
||||
|
||||
@@ -29,14 +29,202 @@
|
||||
|
||||
use std::fmt;
|
||||
use std::ops::Deref;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::bail;
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
mod vcard;
|
||||
pub use vcard::{make_vcard, parse_vcard, VcardContact};
|
||||
#[derive(Debug)]
|
||||
/// A Contact, as represented in a VCard.
|
||||
pub struct VcardContact {
|
||||
/// The email address, vcard property `email`
|
||||
pub addr: String,
|
||||
/// This must be the name authorized by the contact itself, not a locally given name. Vcard
|
||||
/// property `fn`. Can be empty, one should use `display_name()` to obtain the display name.
|
||||
pub authname: String,
|
||||
/// The contact's public PGP key in Base64, vcard property `key`
|
||||
pub key: Option<String>,
|
||||
/// The contact's profile image (=avatar) in Base64, vcard property `photo`
|
||||
pub profile_image: Option<String>,
|
||||
/// The timestamp when the vcard was created / last updated, vcard property `rev`
|
||||
pub timestamp: Result<i64>,
|
||||
}
|
||||
|
||||
impl VcardContact {
|
||||
/// Returns the contact's display name.
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self.authname.is_empty() {
|
||||
false => &self.authname,
|
||||
true => &self.addr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a vCard containing given contacts.
|
||||
///
|
||||
/// Calling [`parse_vcard()`] on the returned result is a reverse operation.
|
||||
pub fn make_vcard(contacts: &[VcardContact]) -> String {
|
||||
fn format_timestamp(c: &VcardContact) -> Option<String> {
|
||||
let timestamp = *c.timestamp.as_ref().ok()?;
|
||||
let datetime = DateTime::from_timestamp(timestamp, 0)?;
|
||||
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
|
||||
}
|
||||
|
||||
let mut res = "".to_string();
|
||||
for c in contacts {
|
||||
let addr = &c.addr;
|
||||
let display_name = c.display_name();
|
||||
res += &format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
EMAIL:{addr}\r\n\
|
||||
FN:{display_name}\r\n"
|
||||
);
|
||||
if let Some(key) = &c.key {
|
||||
res += &format!("KEY:data:application/pgp-keys;base64,{key}\r\n");
|
||||
}
|
||||
if let Some(profile_image) = &c.profile_image {
|
||||
res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\r\n");
|
||||
}
|
||||
if let Some(timestamp) = format_timestamp(c) {
|
||||
res += &format!("REV:{timestamp}\r\n");
|
||||
}
|
||||
res += "END:VCARD\r\n";
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Parses `VcardContact`s from a given `&str`.
|
||||
pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
|
||||
fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let start_of_s = s.get(..prefix.len())?;
|
||||
|
||||
if start_of_s.eq_ignore_ascii_case(prefix) {
|
||||
s.get(prefix.len()..)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn vcard_property<'a>(s: &'a str, property: &str) -> Option<&'a str> {
|
||||
let remainder = remove_prefix(s, property)?;
|
||||
// If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`,
|
||||
// then `remainder` is now `;TYPE=work:alice@example.com`
|
||||
|
||||
// Note: This doesn't handle the case where there are quotes around a colon,
|
||||
// like `NAME;Foo="Some quoted text: that contains a colon":value`.
|
||||
// This could be improved in the future, but for now, the parsing is good enough.
|
||||
let (params, value) = remainder.split_once(':')?;
|
||||
// In the example from above, `params` is now `;TYPE=work`
|
||||
// and `value` is now `alice@example.com`
|
||||
|
||||
if params
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|c| !c.is_ascii_punctuation() || *c == '_')
|
||||
.is_some()
|
||||
{
|
||||
// `s` started with `property`, but the next character after it was not punctuation,
|
||||
// so this line's property is actually something else
|
||||
return None;
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
fn parse_datetime(datetime: &str) -> Result<i64> {
|
||||
// According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp
|
||||
// is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses
|
||||
// ISO.8601, but fails to parse any of the examples given.
|
||||
// So, instead just parse using a format string.
|
||||
|
||||
// Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500.
|
||||
let timestamp = match DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") {
|
||||
Ok(datetime) => datetime.timestamp(),
|
||||
// Parses 19961022T140000.
|
||||
Err(e) => match NaiveDateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S") {
|
||||
Ok(datetime) => datetime
|
||||
.and_local_timezone(chrono::offset::Local)
|
||||
.single()
|
||||
.context("Could not apply local timezone to parsed date and time")?
|
||||
.timestamp(),
|
||||
Err(_) => return Err(e.into()),
|
||||
},
|
||||
};
|
||||
Ok(timestamp)
|
||||
}
|
||||
|
||||
// Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2
|
||||
static NEWLINE_AND_SPACE_OR_TAB: Lazy<Regex> = Lazy::new(|| Regex::new("\r?\n[\t ]").unwrap());
|
||||
let unfolded_lines = NEWLINE_AND_SPACE_OR_TAB.replace_all(vcard, "");
|
||||
|
||||
let mut lines = unfolded_lines.lines().peekable();
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
while lines.peek().is_some() {
|
||||
// Skip to the start of the vcard:
|
||||
for line in lines.by_ref() {
|
||||
if line.eq_ignore_ascii_case("BEGIN:VCARD") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut display_name = None;
|
||||
let mut addr = None;
|
||||
let mut key = None;
|
||||
let mut photo = None;
|
||||
let mut datetime = None;
|
||||
|
||||
for mut line in lines.by_ref() {
|
||||
if let Some(remainder) = remove_prefix(line, "item1.") {
|
||||
// Remove the group name, if the group is called "item1".
|
||||
// If necessary, we can improve this to also remove groups that are called something different that "item1".
|
||||
//
|
||||
// Search "group name" at https://datatracker.ietf.org/doc/html/rfc6350 for more infos.
|
||||
line = remainder;
|
||||
}
|
||||
|
||||
if let Some(email) = vcard_property(line, "email") {
|
||||
addr.get_or_insert(email);
|
||||
} else if let Some(name) = vcard_property(line, "fn") {
|
||||
display_name.get_or_insert(name);
|
||||
} else if let Some(k) = remove_prefix(line, "KEY;PGP;ENCODING=BASE64:")
|
||||
.or_else(|| remove_prefix(line, "KEY;TYPE=PGP;ENCODING=b:"))
|
||||
.or_else(|| remove_prefix(line, "KEY:data:application/pgp-keys;base64,"))
|
||||
.or_else(|| remove_prefix(line, "KEY;PREF=1:data:application/pgp-keys;base64,"))
|
||||
{
|
||||
key.get_or_insert(k);
|
||||
} else if let Some(p) = remove_prefix(line, "PHOTO;JPEG;ENCODING=BASE64:")
|
||||
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=BASE64;JPEG:"))
|
||||
.or_else(|| remove_prefix(line, "PHOTO;TYPE=JPEG;ENCODING=b:"))
|
||||
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=b;TYPE=JPEG:"))
|
||||
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=BASE64;TYPE=JPEG:"))
|
||||
.or_else(|| remove_prefix(line, "PHOTO;TYPE=JPEG;ENCODING=BASE64:"))
|
||||
.or_else(|| remove_prefix(line, "PHOTO:data:image/jpeg;base64,"))
|
||||
{
|
||||
photo.get_or_insert(p);
|
||||
} else if let Some(rev) = vcard_property(line, "rev") {
|
||||
datetime.get_or_insert(rev);
|
||||
} else if line.eq_ignore_ascii_case("END:VCARD") {
|
||||
let (authname, addr) =
|
||||
sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or(""));
|
||||
|
||||
contacts.push(VcardContact {
|
||||
authname,
|
||||
addr,
|
||||
key: key.map(|s| s.to_string()),
|
||||
profile_image: photo.map(|s| s.to_string()),
|
||||
timestamp: datetime
|
||||
.context("No timestamp in vcard")
|
||||
.and_then(parse_datetime),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contacts
|
||||
}
|
||||
|
||||
/// Valid contact address.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -88,8 +276,7 @@ impl rusqlite::types::ToSql for ContactAddress {
|
||||
/// - Removes special characters from the name, see [`sanitize_name()`]
|
||||
/// - Removes the name if it is equal to the address by setting it to ""
|
||||
pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
|
||||
static ADDR_WITH_NAME_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new("(.*)<(.*)>").unwrap());
|
||||
static ADDR_WITH_NAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("(.*)<(.*)>").unwrap());
|
||||
let (name, addr) = if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) {
|
||||
(
|
||||
if name.is_empty() {
|
||||
@@ -291,8 +478,148 @@ impl rusqlite::types::ToSql for EmailAddress {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::TimeZone;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vcard_thunderbird() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:'Alice Mueller'
|
||||
EMAIL;PREF=1:alice.mueller@posteo.de
|
||||
UID:a8083264-ca47-4be7-98a8-8ec3db1447ca
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:'bobzzz@freenet.de'
|
||||
EMAIL;PREF=1:bobzzz@freenet.de
|
||||
UID:cac4fef4-6351-4854-bbe4-9b6df857eaed
|
||||
END:VCARD
|
||||
",
|
||||
);
|
||||
|
||||
assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string());
|
||||
assert_eq!(contacts[0].authname, "Alice Mueller".to_string());
|
||||
assert_eq!(contacts[0].key, None);
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
assert!(contacts[0].timestamp.is_err());
|
||||
|
||||
assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string());
|
||||
assert_eq!(contacts[1].authname, "".to_string());
|
||||
assert_eq!(contacts[1].key, None);
|
||||
assert_eq!(contacts[1].profile_image, None);
|
||||
assert!(contacts[1].timestamp.is_err());
|
||||
|
||||
assert_eq!(contacts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_simple_example() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Alice Wonderland
|
||||
N:Wonderland;Alice;;;Ms.
|
||||
GENDER:W
|
||||
EMAIL;TYPE=work:alice@example.com
|
||||
KEY;TYPE=PGP;ENCODING=b:[base64-data]
|
||||
REV:20240418T184242Z
|
||||
|
||||
END:VCARD",
|
||||
);
|
||||
|
||||
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
|
||||
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
|
||||
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_with_trailing_newline() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD\r
|
||||
VERSION:4.0\r
|
||||
FN:Alice Wonderland\r
|
||||
N:Wonderland;Alice;;;Ms.\r
|
||||
GENDER:W\r
|
||||
EMAIL;TYPE=work:alice@example.com\r
|
||||
KEY;TYPE=PGP;ENCODING=b:[base64-data]\r
|
||||
REV:20240418T184242Z\r
|
||||
END:VCARD\r
|
||||
\r",
|
||||
);
|
||||
|
||||
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
|
||||
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
|
||||
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_and_parse_vcard() {
|
||||
let contacts = [
|
||||
VcardContact {
|
||||
addr: "alice@example.org".to_string(),
|
||||
authname: "Alice Wonderland".to_string(),
|
||||
key: Some("[base64-data]".to_string()),
|
||||
profile_image: Some("image in Base64".to_string()),
|
||||
timestamp: Ok(1713465762),
|
||||
},
|
||||
VcardContact {
|
||||
addr: "bob@example.com".to_string(),
|
||||
authname: "".to_string(),
|
||||
key: None,
|
||||
profile_image: None,
|
||||
timestamp: Ok(0),
|
||||
},
|
||||
];
|
||||
let items = [
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
EMAIL:alice@example.org\r\n\
|
||||
FN:Alice Wonderland\r\n\
|
||||
KEY:data:application/pgp-keys;base64,[base64-data]\r\n\
|
||||
PHOTO:data:image/jpeg;base64,image in Base64\r\n\
|
||||
REV:20240418T184242Z\r\n\
|
||||
END:VCARD\r\n",
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
EMAIL:bob@example.com\r\n\
|
||||
FN:bob@example.com\r\n\
|
||||
REV:19700101T000000Z\r\n\
|
||||
END:VCARD\r\n",
|
||||
];
|
||||
let mut expected = "".to_string();
|
||||
for len in 0..=contacts.len() {
|
||||
let contacts = &contacts[0..len];
|
||||
let vcard = make_vcard(contacts);
|
||||
if len > 0 {
|
||||
expected += items[len - 1];
|
||||
}
|
||||
assert_eq!(vcard, expected);
|
||||
let parsed = parse_vcard(&vcard);
|
||||
assert_eq!(parsed.len(), contacts.len());
|
||||
for i in 0..parsed.len() {
|
||||
assert_eq!(parsed[i].addr, contacts[i].addr);
|
||||
assert_eq!(parsed[i].authname, contacts[i].authname);
|
||||
assert_eq!(parsed[i].key, contacts[i].key);
|
||||
assert_eq!(parsed[i].profile_image, contacts[i].profile_image);
|
||||
assert_eq!(
|
||||
parsed[i].timestamp.as_ref().unwrap(),
|
||||
contacts[i].timestamp.as_ref().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contact_address() -> Result<()> {
|
||||
let alice_addr = "alice@example.org";
|
||||
@@ -339,6 +666,112 @@ mod tests {
|
||||
assert_eq!(EmailAddress::new("@d.tt").is_ok(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_android() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N:;Bob;;;
|
||||
FN:Bob
|
||||
TEL;CELL:+1-234-567-890
|
||||
EMAIL;HOME:bob@example.org
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N:;Alice;;;
|
||||
FN:Alice
|
||||
EMAIL;HOME:alice@example.org
|
||||
END:VCARD
|
||||
",
|
||||
);
|
||||
|
||||
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
|
||||
assert_eq!(contacts[0].authname, "Bob".to_string());
|
||||
assert_eq!(contacts[0].key, None);
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
|
||||
assert_eq!(contacts[1].addr, "alice@example.org".to_string());
|
||||
assert_eq!(contacts[1].authname, "Alice".to_string());
|
||||
assert_eq!(contacts[1].key, None);
|
||||
assert_eq!(contacts[1].profile_image, None);
|
||||
|
||||
assert_eq!(contacts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_local_datetime() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD\n\
|
||||
VERSION:4.0\n\
|
||||
FN:Alice Wonderland\n\
|
||||
EMAIL;TYPE=work:alice@example.org\n\
|
||||
REV:20240418T184242\n\
|
||||
END:VCARD",
|
||||
);
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].addr, "alice@example.org".to_string());
|
||||
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
|
||||
assert_eq!(
|
||||
*contacts[0].timestamp.as_ref().unwrap(),
|
||||
chrono::offset::Local
|
||||
.with_ymd_and_hms(2024, 4, 18, 18, 42, 42)
|
||||
.unwrap()
|
||||
.timestamp()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_with_base64_avatar() {
|
||||
// This is not an actual base64-encoded avatar, it's just to test the parsing.
|
||||
// This one is Android-like.
|
||||
let vcard0 = "BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N:;Bob;;;
|
||||
FN:Bob
|
||||
EMAIL;HOME:bob@example.org
|
||||
PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEU
|
||||
AAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAA
|
||||
L8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==
|
||||
|
||||
END:VCARD
|
||||
";
|
||||
// This one is DOS-like.
|
||||
let vcard1 = vcard0.replace('\n', "\r\n");
|
||||
for vcard in [vcard0, vcard1.as_str()] {
|
||||
let contacts = parse_vcard(vcard);
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
|
||||
assert_eq!(contacts[0].authname, "Bob".to_string());
|
||||
assert_eq!(contacts[0].key, None);
|
||||
assert_eq!(contacts[0].profile_image.as_deref().unwrap(), "/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEUAAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAAL8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protonmail_vcard() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN;PREF=1:Alice Wonderland
|
||||
UID:proton-web-03747582-328d-38dc-5ddd-000000000000
|
||||
ITEM1.EMAIL;PREF=1:alice@example.org
|
||||
ITEM1.KEY;PREF=1:data:application/pgp-keys;base64,aaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
ITEM1.KEY;PREF=2:data:application/pgp-keys;base64,bbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
ITEM1.X-PM-ENCRYPT:true
|
||||
ITEM1.X-PM-SIGN:true
|
||||
END:VCARD",
|
||||
);
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(&contacts[0].addr, "alice@example.org");
|
||||
assert_eq!(&contacts[0].authname, "Alice Wonderland");
|
||||
assert_eq!(contacts[0].key.as_ref().unwrap(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
assert!(contacts[0].timestamp.is_err());
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_name() {
|
||||
assert_eq!(&sanitize_name(" hello world "), "hello world");
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::sanitize_name_and_addr;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// A Contact, as represented in a VCard.
|
||||
pub struct VcardContact {
|
||||
/// The email address, vcard property `email`
|
||||
pub addr: String,
|
||||
/// This must be the name authorized by the contact itself, not a locally given name. Vcard
|
||||
/// property `fn`. Can be empty, one should use `display_name()` to obtain the display name.
|
||||
pub authname: String,
|
||||
/// The contact's public PGP key in Base64, vcard property `key`
|
||||
pub key: Option<String>,
|
||||
/// The contact's profile image (=avatar) in Base64, vcard property `photo`
|
||||
pub profile_image: Option<String>,
|
||||
/// The timestamp when the vcard was created / last updated, vcard property `rev`
|
||||
pub timestamp: Result<i64>,
|
||||
}
|
||||
|
||||
impl VcardContact {
|
||||
/// Returns the contact's display name.
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self.authname.is_empty() {
|
||||
false => &self.authname,
|
||||
true => &self.addr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a vCard containing given contacts.
|
||||
///
|
||||
/// Calling [`parse_vcard()`] on the returned result is a reverse operation.
|
||||
pub fn make_vcard(contacts: &[VcardContact]) -> String {
|
||||
fn format_timestamp(c: &VcardContact) -> Option<String> {
|
||||
let timestamp = *c.timestamp.as_ref().ok()?;
|
||||
let datetime = DateTime::from_timestamp(timestamp, 0)?;
|
||||
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
|
||||
}
|
||||
|
||||
let mut res = "".to_string();
|
||||
for c in contacts {
|
||||
let addr = &c.addr;
|
||||
let display_name = c.display_name();
|
||||
res += &format!(
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
EMAIL:{addr}\r\n\
|
||||
FN:{display_name}\r\n"
|
||||
);
|
||||
if let Some(key) = &c.key {
|
||||
res += &format!("KEY:data:application/pgp-keys;base64,{key}\r\n");
|
||||
}
|
||||
if let Some(profile_image) = &c.profile_image {
|
||||
res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\r\n");
|
||||
}
|
||||
if let Some(timestamp) = format_timestamp(c) {
|
||||
res += &format!("REV:{timestamp}\r\n");
|
||||
}
|
||||
res += "END:VCARD\r\n";
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Parses `VcardContact`s from a given `&str`.
|
||||
pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
|
||||
fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let start_of_s = s.get(..prefix.len())?;
|
||||
|
||||
if start_of_s.eq_ignore_ascii_case(prefix) {
|
||||
s.get(prefix.len()..)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Returns (parameters, value) tuple.
|
||||
fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, &'a str)> {
|
||||
let remainder = remove_prefix(line, property)?;
|
||||
// If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`,
|
||||
// then `remainder` is now `;TYPE=work:alice@example.com`
|
||||
|
||||
// Note: This doesn't handle the case where there are quotes around a colon,
|
||||
// like `NAME;Foo="Some quoted text: that contains a colon":value`.
|
||||
// This could be improved in the future, but for now, the parsing is good enough.
|
||||
let (mut params, value) = remainder.split_once(':')?;
|
||||
// In the example from above, `params` is now `;TYPE=work`
|
||||
// and `value` is now `alice@example.com`
|
||||
|
||||
if params
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|c| !c.is_ascii_punctuation() || *c == '_')
|
||||
.is_some()
|
||||
{
|
||||
// `s` started with `property`, but the next character after it was not punctuation,
|
||||
// so this line's property is actually something else
|
||||
return None;
|
||||
}
|
||||
if let Some(p) = remove_prefix(params, ";") {
|
||||
params = p;
|
||||
}
|
||||
if let Some(p) = remove_prefix(params, "PREF=1") {
|
||||
params = p;
|
||||
}
|
||||
Some((params, value))
|
||||
}
|
||||
fn base64_key(line: &str) -> Option<&str> {
|
||||
let (params, value) = vcard_property(line, "key")?;
|
||||
if params.eq_ignore_ascii_case("PGP;ENCODING=BASE64")
|
||||
|| params.eq_ignore_ascii_case("TYPE=PGP;ENCODING=b")
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
if let Some(value) = remove_prefix(value, "data:application/pgp-keys;base64,")
|
||||
.or_else(|| remove_prefix(value, r"data:application/pgp-keys;base64\,"))
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
fn base64_photo(line: &str) -> Option<&str> {
|
||||
let (params, value) = vcard_property(line, "photo")?;
|
||||
if params.eq_ignore_ascii_case("JPEG;ENCODING=BASE64")
|
||||
|| params.eq_ignore_ascii_case("ENCODING=BASE64;JPEG")
|
||||
|| params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=b")
|
||||
|| params.eq_ignore_ascii_case("ENCODING=b;TYPE=JPEG")
|
||||
|| params.eq_ignore_ascii_case("ENCODING=BASE64;TYPE=JPEG")
|
||||
|| params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=BASE64")
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
if let Some(value) = remove_prefix(value, "data:image/jpeg;base64,")
|
||||
.or_else(|| remove_prefix(value, r"data:image/jpeg;base64\,"))
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
fn parse_datetime(datetime: &str) -> Result<i64> {
|
||||
// According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp
|
||||
// is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses
|
||||
// ISO.8601, but fails to parse any of the examples given.
|
||||
// So, instead just parse using a format string.
|
||||
|
||||
// Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500.
|
||||
let timestamp = match DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") {
|
||||
Ok(datetime) => datetime.timestamp(),
|
||||
// Parses 19961022T140000.
|
||||
Err(e) => match NaiveDateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S") {
|
||||
Ok(datetime) => datetime
|
||||
.and_local_timezone(chrono::offset::Local)
|
||||
.single()
|
||||
.context("Could not apply local timezone to parsed date and time")?
|
||||
.timestamp(),
|
||||
Err(_) => return Err(e.into()),
|
||||
},
|
||||
};
|
||||
Ok(timestamp)
|
||||
}
|
||||
|
||||
// Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2
|
||||
static NEWLINE_AND_SPACE_OR_TAB: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new("\r?\n[\t ]").unwrap());
|
||||
let unfolded_lines = NEWLINE_AND_SPACE_OR_TAB.replace_all(vcard, "");
|
||||
|
||||
let mut lines = unfolded_lines.lines().peekable();
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
while lines.peek().is_some() {
|
||||
// Skip to the start of the vcard:
|
||||
for line in lines.by_ref() {
|
||||
if line.eq_ignore_ascii_case("BEGIN:VCARD") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut display_name = None;
|
||||
let mut addr = None;
|
||||
let mut key = None;
|
||||
let mut photo = None;
|
||||
let mut datetime = None;
|
||||
|
||||
for mut line in lines.by_ref() {
|
||||
if let Some(remainder) = remove_prefix(line, "item1.") {
|
||||
// Remove the group name, if the group is called "item1".
|
||||
// If necessary, we can improve this to also remove groups that are called something different that "item1".
|
||||
//
|
||||
// Search "group name" at https://datatracker.ietf.org/doc/html/rfc6350 for more infos.
|
||||
line = remainder;
|
||||
}
|
||||
|
||||
if let Some((_params, email)) = vcard_property(line, "email") {
|
||||
addr.get_or_insert(email);
|
||||
} else if let Some((_params, name)) = vcard_property(line, "fn") {
|
||||
display_name.get_or_insert(name);
|
||||
} else if let Some(k) = base64_key(line) {
|
||||
key.get_or_insert(k);
|
||||
} else if let Some(p) = base64_photo(line) {
|
||||
photo.get_or_insert(p);
|
||||
} else if let Some((_params, rev)) = vcard_property(line, "rev") {
|
||||
datetime.get_or_insert(rev);
|
||||
} else if line.eq_ignore_ascii_case("END:VCARD") {
|
||||
let (authname, addr) =
|
||||
sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or(""));
|
||||
|
||||
contacts.push(VcardContact {
|
||||
authname,
|
||||
addr,
|
||||
key: key.map(|s| s.to_string()),
|
||||
profile_image: photo.map(|s| s.to_string()),
|
||||
timestamp: datetime
|
||||
.context("No timestamp in vcard")
|
||||
.and_then(parse_datetime),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contacts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vcard_tests;
|
||||
@@ -1,274 +0,0 @@
|
||||
use chrono::TimeZone as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vcard_thunderbird() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:'Alice Mueller'
|
||||
EMAIL;PREF=1:alice.mueller@posteo.de
|
||||
UID:a8083264-ca47-4be7-98a8-8ec3db1447ca
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:'bobzzz@freenet.de'
|
||||
EMAIL;PREF=1:bobzzz@freenet.de
|
||||
UID:cac4fef4-6351-4854-bbe4-9b6df857eaed
|
||||
END:VCARD
|
||||
",
|
||||
);
|
||||
|
||||
assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string());
|
||||
assert_eq!(contacts[0].authname, "Alice Mueller".to_string());
|
||||
assert_eq!(contacts[0].key, None);
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
assert!(contacts[0].timestamp.is_err());
|
||||
|
||||
assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string());
|
||||
assert_eq!(contacts[1].authname, "".to_string());
|
||||
assert_eq!(contacts[1].key, None);
|
||||
assert_eq!(contacts[1].profile_image, None);
|
||||
assert!(contacts[1].timestamp.is_err());
|
||||
|
||||
assert_eq!(contacts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_simple_example() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Alice Wonderland
|
||||
N:Wonderland;Alice;;;Ms.
|
||||
GENDER:W
|
||||
EMAIL;TYPE=work:alice@example.com
|
||||
KEY;TYPE=PGP;ENCODING=b:[base64-data]
|
||||
REV:20240418T184242Z
|
||||
|
||||
END:VCARD",
|
||||
);
|
||||
|
||||
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
|
||||
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
|
||||
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_with_trailing_newline() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD\r
|
||||
VERSION:4.0\r
|
||||
FN:Alice Wonderland\r
|
||||
N:Wonderland;Alice;;;Ms.\r
|
||||
GENDER:W\r
|
||||
EMAIL;TYPE=work:alice@example.com\r
|
||||
KEY;TYPE=PGP;ENCODING=b:[base64-data]\r
|
||||
REV:20240418T184242Z\r
|
||||
END:VCARD\r
|
||||
\r",
|
||||
);
|
||||
|
||||
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
|
||||
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
|
||||
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_and_parse_vcard() {
|
||||
let contacts = [
|
||||
VcardContact {
|
||||
addr: "alice@example.org".to_string(),
|
||||
authname: "Alice Wonderland".to_string(),
|
||||
key: Some("[base64-data]".to_string()),
|
||||
profile_image: Some("image in Base64".to_string()),
|
||||
timestamp: Ok(1713465762),
|
||||
},
|
||||
VcardContact {
|
||||
addr: "bob@example.com".to_string(),
|
||||
authname: "".to_string(),
|
||||
key: None,
|
||||
profile_image: None,
|
||||
timestamp: Ok(0),
|
||||
},
|
||||
];
|
||||
let items = [
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
EMAIL:alice@example.org\r\n\
|
||||
FN:Alice Wonderland\r\n\
|
||||
KEY:data:application/pgp-keys;base64,[base64-data]\r\n\
|
||||
PHOTO:data:image/jpeg;base64,image in Base64\r\n\
|
||||
REV:20240418T184242Z\r\n\
|
||||
END:VCARD\r\n",
|
||||
"BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
EMAIL:bob@example.com\r\n\
|
||||
FN:bob@example.com\r\n\
|
||||
REV:19700101T000000Z\r\n\
|
||||
END:VCARD\r\n",
|
||||
];
|
||||
let mut expected = "".to_string();
|
||||
for len in 0..=contacts.len() {
|
||||
let contacts = &contacts[0..len];
|
||||
let vcard = make_vcard(contacts);
|
||||
if len > 0 {
|
||||
expected += items[len - 1];
|
||||
}
|
||||
assert_eq!(vcard, expected);
|
||||
let parsed = parse_vcard(&vcard);
|
||||
assert_eq!(parsed.len(), contacts.len());
|
||||
for i in 0..parsed.len() {
|
||||
assert_eq!(parsed[i].addr, contacts[i].addr);
|
||||
assert_eq!(parsed[i].authname, contacts[i].authname);
|
||||
assert_eq!(parsed[i].key, contacts[i].key);
|
||||
assert_eq!(parsed[i].profile_image, contacts[i].profile_image);
|
||||
assert_eq!(
|
||||
parsed[i].timestamp.as_ref().unwrap(),
|
||||
contacts[i].timestamp.as_ref().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_android() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N:;Bob;;;
|
||||
FN:Bob
|
||||
TEL;CELL:+1-234-567-890
|
||||
EMAIL;HOME:bob@example.org
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N:;Alice;;;
|
||||
FN:Alice
|
||||
EMAIL;HOME:alice@example.org
|
||||
END:VCARD
|
||||
",
|
||||
);
|
||||
|
||||
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
|
||||
assert_eq!(contacts[0].authname, "Bob".to_string());
|
||||
assert_eq!(contacts[0].key, None);
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
|
||||
assert_eq!(contacts[1].addr, "alice@example.org".to_string());
|
||||
assert_eq!(contacts[1].authname, "Alice".to_string());
|
||||
assert_eq!(contacts[1].key, None);
|
||||
assert_eq!(contacts[1].profile_image, None);
|
||||
|
||||
assert_eq!(contacts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_local_datetime() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD\n\
|
||||
VERSION:4.0\n\
|
||||
FN:Alice Wonderland\n\
|
||||
EMAIL;TYPE=work:alice@example.org\n\
|
||||
REV:20240418T184242\n\
|
||||
END:VCARD",
|
||||
);
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].addr, "alice@example.org".to_string());
|
||||
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
|
||||
assert_eq!(
|
||||
*contacts[0].timestamp.as_ref().unwrap(),
|
||||
chrono::offset::Local
|
||||
.with_ymd_and_hms(2024, 4, 18, 18, 42, 42)
|
||||
.unwrap()
|
||||
.timestamp()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vcard_with_base64_avatar() {
|
||||
// This is not an actual base64-encoded avatar, it's just to test the parsing.
|
||||
// This one is Android-like.
|
||||
let vcard0 = "BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N:;Bob;;;
|
||||
FN:Bob
|
||||
EMAIL;HOME:bob@example.org
|
||||
PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEU
|
||||
AAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAA
|
||||
L8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==
|
||||
|
||||
END:VCARD
|
||||
";
|
||||
// This one is DOS-like.
|
||||
let vcard1 = vcard0.replace('\n', "\r\n");
|
||||
for vcard in [vcard0, vcard1.as_str()] {
|
||||
let contacts = parse_vcard(vcard);
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
|
||||
assert_eq!(contacts[0].authname, "Bob".to_string());
|
||||
assert_eq!(contacts[0].key, None);
|
||||
assert_eq!(contacts[0].profile_image.as_deref().unwrap(), "/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEUAAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAAL8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protonmail_vcard() {
|
||||
let contacts = parse_vcard(
|
||||
"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN;PREF=1:Alice Wonderland
|
||||
UID:proton-web-03747582-328d-38dc-5ddd-000000000000
|
||||
ITEM1.EMAIL;PREF=1:alice@example.org
|
||||
ITEM1.KEY;PREF=1:data:application/pgp-keys;base64,aaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
ITEM1.KEY;PREF=2:data:application/pgp-keys;base64,bbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
ITEM1.X-PM-ENCRYPT:true
|
||||
ITEM1.X-PM-SIGN:true
|
||||
END:VCARD",
|
||||
);
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(&contacts[0].addr, "alice@example.org");
|
||||
assert_eq!(&contacts[0].authname, "Alice Wonderland");
|
||||
assert_eq!(contacts[0].key.as_ref().unwrap(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
assert!(contacts[0].timestamp.is_err());
|
||||
assert_eq!(contacts[0].profile_image, None);
|
||||
}
|
||||
|
||||
/// Proton at some point slightly changed the format of their vcards
|
||||
#[test]
|
||||
fn test_protonmail_vcard2() {
|
||||
let contacts = parse_vcard(
|
||||
r"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN;PREF=1:Alice
|
||||
PHOTO;PREF=1:data:image/jpeg;base64,/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z
|
||||
REV:Invalid Date
|
||||
ITEM1.EMAIL;PREF=1:alice@example.org
|
||||
KEY;PREF=1:data:application/pgp-keys;base64,xsaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa==
|
||||
UID:proton-web-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
|
||||
END:VCARD",
|
||||
);
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(&contacts[0].addr, "alice@example.org");
|
||||
assert_eq!(&contacts[0].authname, "Alice");
|
||||
assert_eq!(contacts[0].key.as_ref().unwrap(), "xsaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa==");
|
||||
assert!(contacts[0].timestamp.is_err());
|
||||
assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "1.159.3"
|
||||
version = "1.157.1"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
@@ -24,6 +24,7 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
yerpc = { workspace = true, features = ["anyhow_expose"] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -2162,29 +2162,6 @@ uint32_t dc_create_contact (dc_context_t* context, const char*
|
||||
int dc_add_address_book (dc_context_t* context, const char* addr_book);
|
||||
|
||||
|
||||
/**
|
||||
* Make a vCard.
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @param contact_id The ID of the contact to make the vCard of.
|
||||
* @return vCard, must be released using dc_str_unref() after usage.
|
||||
*/
|
||||
char* dc_make_vcard (dc_context_t* context, uint32_t contact_id);
|
||||
|
||||
|
||||
/**
|
||||
* Import a vCard.
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @param vcard vCard contents.
|
||||
* @return Returns the IDs of the contacts in the order they appear in the vCard.
|
||||
* Must be dc_array_unref()'d after usage.
|
||||
*/
|
||||
dc_array_t* dc_import_vcard (dc_context_t* context, const char* vcard);
|
||||
|
||||
|
||||
/**
|
||||
* Returns known and unblocked contacts.
|
||||
*
|
||||
@@ -4506,11 +4483,6 @@ int dc_msg_is_info (const dc_msg_t* msg);
|
||||
* UIs can display e.g. an icon based upon the type.
|
||||
*
|
||||
* Currently, the following types are defined:
|
||||
* - DC_INFO_GROUP_NAME_CHANGED (2) - "Group name changd from OLD to BY by CONTACT"
|
||||
* - DC_INFO_GROUP_IMAGE_CHANGED (3) - "Group image changd by CONTACT"
|
||||
* - DC_INFO_MEMBER_ADDED_TO_GROUP (4) - "Member CONTACT added by OTHER_CONTACT"
|
||||
* - DC_INFO_MEMBER_REMOVED_FROM_GROUP (5) - "Member CONTACT removed by OTHER_CONTACT"
|
||||
* - DC_INFO_EPHEMERAL_TIMER_CHANGED (10) - "Disappearing messages CHANGED_TO by CONTACT"
|
||||
* - DC_INFO_PROTECTION_ENABLED (11) - Info-message for "Chat is now protected"
|
||||
* - DC_INFO_PROTECTION_DISABLED (12) - Info-message for "Chat is no longer protected"
|
||||
* - DC_INFO_INVALID_UNENCRYPTED_MAIL (13) - Info-message for "Provider requires end-to-end encryption which is not setup yet",
|
||||
@@ -4518,10 +4490,6 @@ int dc_msg_is_info (const dc_msg_t* msg);
|
||||
* and also offer a way to fix the encryption, eg. by a button offering a QR scan
|
||||
* - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info`
|
||||
*
|
||||
* For the messages that refer to a CONTACT,
|
||||
* dc_msg_get_info_contact_id() returns the contact ID.
|
||||
* The UI should open the contact's profile when tapping the info message.
|
||||
*
|
||||
* Even when you display an icon,
|
||||
* you should still display the text of the informational message using dc_msg_get_text()
|
||||
*
|
||||
@@ -4534,29 +4502,6 @@ int dc_msg_is_info (const dc_msg_t* msg);
|
||||
int dc_msg_get_info_type (const dc_msg_t* msg);
|
||||
|
||||
|
||||
/**
|
||||
* Return the contact ID of the profile to open when tapping the info message.
|
||||
*
|
||||
* - For DC_INFO_MEMBER_ADDED_TO_GROUP and DC_INFO_MEMBER_REMOVED_FROM_GROUP,
|
||||
* this is the contact being added/removed.
|
||||
* The contact that did the adding/removal is usually only a tap away
|
||||
* (as introducer and/or atop of the memberlist),
|
||||
* and usually more known anyways.
|
||||
* - For DC_INFO_GROUP_NAME_CHANGED, DC_INFO_GROUP_IMAGE_CHANGED and DC_INFO_EPHEMERAL_TIMER_CHANGED
|
||||
* this is the contact who did the change.
|
||||
*
|
||||
* No need to check additionally for dc_msg_get_info_type(),
|
||||
* unless you e.g. want to show the info message in another style.
|
||||
*
|
||||
* @memberof dc_msg_t
|
||||
* @param msg The message object.
|
||||
* @return If the info message refers to a contact,
|
||||
* this contact ID or DC_CONTACT_ID_SELF is returned.
|
||||
* Otherwise 0.
|
||||
*/
|
||||
uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
|
||||
|
||||
|
||||
// DC_INFO* uses the same values as SystemMessage in rust-land
|
||||
#define DC_INFO_UNKNOWN 0
|
||||
#define DC_INFO_GROUP_NAME_CHANGED 2
|
||||
@@ -6912,12 +6857,12 @@ void dc_event_unref(dc_event_t* event);
|
||||
|
||||
/// "Autocrypt Setup Message"
|
||||
///
|
||||
/// @deprecated 2025-04
|
||||
/// Used in subjects of outgoing Autocrypt Setup Messages.
|
||||
#define DC_STR_AC_SETUP_MSG_SUBJECT 42
|
||||
|
||||
/// "This is the Autocrypt Setup Message, open it in a compatible client to use your setup"
|
||||
///
|
||||
/// @deprecated 2025-04
|
||||
/// Used as message text of outgoing Autocrypt Setup Messages.
|
||||
#define DC_STR_AC_SETUP_MSG_BODY 43
|
||||
|
||||
/// "Cannot login as %1$s."
|
||||
@@ -7592,13 +7537,8 @@ void dc_event_unref(dc_event_t* event);
|
||||
|
||||
/// "Could not yet establish guaranteed end-to-end encryption, but you may already send a message."
|
||||
///
|
||||
/// @deprecated 2025-03
|
||||
#define DC_STR_SECUREJOIN_WAIT_TIMEOUT 191
|
||||
|
||||
/// "The contact must be online to proceed. This process will continue automatically in background."
|
||||
///
|
||||
/// Used as info message.
|
||||
#define DC_STR_SECUREJOIN_TAKES_LONGER 192
|
||||
#define DC_STR_SECUREJOIN_WAIT_TIMEOUT 191
|
||||
|
||||
/// "Contact". Deprecated, currently unused.
|
||||
#define DC_STR_CONTACT 200
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::future::Future;
|
||||
use std::ops::Deref;
|
||||
use std::ptr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::Context as _;
|
||||
@@ -37,8 +37,8 @@ use deltachat::*;
|
||||
use deltachat::{accounts::Accounts, log::LogExt};
|
||||
use deltachat_jsonrpc::api::CommandApi;
|
||||
use deltachat_jsonrpc::yerpc::{OutReceiver, RpcClient, RpcSession};
|
||||
use message::Viewtype;
|
||||
use num_traits::{FromPrimitive, ToPrimitive};
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::Rng;
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -68,8 +68,7 @@ const DC_GCM_INFO_ONLY: u32 = 0x02;
|
||||
/// Struct representing the deltachat context.
|
||||
pub type dc_context_t = Context;
|
||||
|
||||
static RT: LazyLock<Runtime> =
|
||||
LazyLock::new(|| Runtime::new().expect("unable to create tokio runtime"));
|
||||
static RT: Lazy<Runtime> = Lazy::new(|| Runtime::new().expect("unable to create tokio runtime"));
|
||||
|
||||
fn block_on<T>(fut: T) -> T::Output
|
||||
where
|
||||
@@ -537,7 +536,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
|
||||
EventType::IncomingReaction { .. } => 2002,
|
||||
EventType::IncomingWebxdcNotify { .. } => 2003,
|
||||
EventType::IncomingMsg { .. } => 2005,
|
||||
EventType::IncomingMsgBunch => 2006,
|
||||
EventType::IncomingMsgBunch { .. } => 2006,
|
||||
EventType::MsgsNoticed { .. } => 2008,
|
||||
EventType::MsgDelivered { .. } => 2010,
|
||||
EventType::MsgFailed { .. } => 2012,
|
||||
@@ -595,7 +594,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::ConnectivityChanged
|
||||
| EventType::SelfavatarChanged
|
||||
| EventType::ConfigSynced { .. }
|
||||
| EventType::IncomingMsgBunch
|
||||
| EventType::IncomingMsgBunch { .. }
|
||||
| EventType::ErrorSelfNotInGroup(_)
|
||||
| EventType::AccountsBackgroundFetchDone
|
||||
| EventType::ChatlistChanged
|
||||
@@ -670,7 +669,7 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::MsgsNoticed(_)
|
||||
| EventType::ConnectivityChanged
|
||||
| EventType::WebxdcInstanceDeleted { .. }
|
||||
| EventType::IncomingMsgBunch
|
||||
| EventType::IncomingMsgBunch { .. }
|
||||
| EventType::SelfavatarChanged
|
||||
| EventType::AccountsBackgroundFetchDone
|
||||
| EventType::ChatlistChanged
|
||||
@@ -772,7 +771,7 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
|
||||
| EventType::AccountsBackgroundFetchDone
|
||||
| EventType::ChatEphemeralTimerModified { .. }
|
||||
| EventType::ChatDeleted { .. }
|
||||
| EventType::IncomingMsgBunch
|
||||
| EventType::IncomingMsgBunch { .. }
|
||||
| EventType::ChatlistItemChanged { .. }
|
||||
| EventType::ChatlistChanged
|
||||
| EventType::AccountsChanged
|
||||
@@ -2077,7 +2076,7 @@ pub unsafe extern "C" fn dc_get_msg(context: *mut dc_context_t, msg_id: u32) ->
|
||||
ctx,
|
||||
"dc_get_msg called with special msg_id={msg_id}, returning empty msg"
|
||||
);
|
||||
message::Message::new(Viewtype::default())
|
||||
message::Message::default()
|
||||
} else {
|
||||
warn!(ctx, "dc_get_msg could not retrieve msg_id {msg_id}: {e:#}");
|
||||
return ptr::null_mut();
|
||||
@@ -2171,48 +2170,6 @@ pub unsafe extern "C" fn dc_add_address_book(
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_make_vcard(
|
||||
context: *mut dc_context_t,
|
||||
contact_id: u32,
|
||||
) -> *mut libc::c_char {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_make_vcard()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let ctx = &*context;
|
||||
let contact_id = ContactId::new(contact_id);
|
||||
|
||||
block_on(contact::make_vcard(ctx, &[contact_id]))
|
||||
.unwrap_or_log_default(ctx, "dc_make_vcard failed")
|
||||
.strdup()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_import_vcard(
|
||||
context: *mut dc_context_t,
|
||||
vcard: *const libc::c_char,
|
||||
) -> *mut dc_array::dc_array_t {
|
||||
if context.is_null() || vcard.is_null() {
|
||||
eprintln!("ignoring careless call to dc_import_vcard()");
|
||||
return ptr::null_mut();
|
||||
}
|
||||
let ctx = &*context;
|
||||
|
||||
match block_on(contact::import_vcard(ctx, &to_string_lossy(vcard)))
|
||||
.context("dc_import_vcard failed")
|
||||
.log_err(ctx)
|
||||
{
|
||||
Ok(contact_ids) => Box::into_raw(Box::new(dc_array_t::from(
|
||||
contact_ids
|
||||
.iter()
|
||||
.map(|id| id.to_u32())
|
||||
.collect::<Vec<u32>>(),
|
||||
))),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_get_contacts(
|
||||
context: *mut dc_context_t,
|
||||
@@ -3773,20 +3730,6 @@ pub unsafe extern "C" fn dc_msg_get_info_type(msg: *mut dc_msg_t) -> libc::c_int
|
||||
ffi_msg.message.get_info_type() as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_msg_get_info_contact_id(msg: *mut dc_msg_t) -> u32 {
|
||||
if msg.is_null() {
|
||||
eprintln!("ignoring careless call to dc_msg_get_info_contact_id()");
|
||||
return 0;
|
||||
}
|
||||
let ffi_msg = &*msg;
|
||||
let context = &*ffi_msg.context;
|
||||
block_on(ffi_msg.message.get_info_contact_id(context))
|
||||
.unwrap_or_default()
|
||||
.map(|id| id.to_u32())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_msg_get_webxdc_href(msg: *mut dc_msg_t) -> *mut libc::c_char {
|
||||
if msg.is_null() {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "1.159.3"
|
||||
version = "1.157.1"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
default-run = "deltachat-jsonrpc-server"
|
||||
license = "MPL-2.0"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
[[bin]]
|
||||
name = "deltachat-jsonrpc-server"
|
||||
path = "src/webserver.rs"
|
||||
required-features = ["webserver"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
deltachat = { workspace = true }
|
||||
@@ -13,7 +19,10 @@ deltachat-contact-tools = { workspace = true }
|
||||
num-traits = { workspace = true }
|
||||
schemars = "0.8.22"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tempfile = { workspace = true }
|
||||
log = { workspace = true }
|
||||
async-channel = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
yerpc = { workspace = true, features = ["anyhow_expose", "openrpc"] }
|
||||
typescript-type-def = { version = "0.5.13", features = ["json_value"] }
|
||||
@@ -22,12 +31,15 @@ sanitize-filename = { workspace = true }
|
||||
walkdir = "2.5.0"
|
||||
base64 = { workspace = true }
|
||||
|
||||
# optional dependencies
|
||||
axum = { version = "0.7", optional = true, features = ["ws"] }
|
||||
env_logger = { version = "0.11.6", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full", "rt-multi-thread"] }
|
||||
tempfile = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
|
||||
[features]
|
||||
default = ["vendored"]
|
||||
webserver = ["dep:env_logger", "dep:axum", "tokio/full", "yerpc/support-axum"]
|
||||
vendored = ["deltachat/vendored"]
|
||||
|
||||
@@ -4,16 +4,46 @@ This crate provides a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) inte
|
||||
|
||||
The JSON-RPC API is exposed in two fashions:
|
||||
|
||||
* A executable `deltachat-rpc-server` that exposes the JSON-RPC API through stdio.
|
||||
* The JSON-RPC API can also be called through the [C FFI](../deltachat-ffi). It exposes the functions `dc_jsonrpc_init`, `dc_jsonrpc_request`, `dc_jsonrpc_next_response` and `dc_jsonrpc_unref`. See the docs in the [header file](../deltachat-ffi/deltachat.h) for details.
|
||||
* A executable that exposes the JSON-RPC API through a [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) server running on localhost.
|
||||
* The JSON-RPC API can also be called through the [C FFI](../deltachat-ffi). The C FFI needs to be built with the `jsonrpc` feature. It will then expose the functions `dc_jsonrpc_init`, `dc_jsonrpc_request`, `dc_jsonrpc_next_response` and `dc_jsonrpc_unref`. See the docs in the [header file](../deltachat-ffi/deltachat.h) for details.
|
||||
|
||||
We also include a JavaScript and TypeScript client for the JSON-RPC API. The source for this is in the [`typescript`](typescript) folder.
|
||||
We also include a JavaScript and TypeScript client for the JSON-RPC API. The source for this is in the [`typescript`](typescript) folder. The client can easily be used with the WebSocket server to build DeltaChat apps for web browsers or Node.js. See the [examples](typescript/example) for details.
|
||||
|
||||
## Usage
|
||||
|
||||
#### Running the WebSocket server
|
||||
|
||||
From within this folder, you can start the WebSocket server with the following command:
|
||||
|
||||
```sh
|
||||
cargo run --features webserver
|
||||
```
|
||||
|
||||
If you want to use the server in a production setup, first build it in release mode:
|
||||
|
||||
```sh
|
||||
cargo build --features webserver --release
|
||||
```
|
||||
You will then find the `deltachat-jsonrpc-server` executable in your `target/release` folder.
|
||||
|
||||
The executable currently does not support any command-line arguments. By default, once started it will accept WebSocket connections on `ws://localhost:20808/ws`. It will store the persistent configuration and databases in a `./accounts` folder relative to the directory from where it is started.
|
||||
|
||||
The server can be configured with environment variables:
|
||||
|
||||
|variable|default|description|
|
||||
|-|-|-|
|
||||
|`DC_PORT`|`20808`|port to listen on|
|
||||
|`DC_ACCOUNTS_PATH`|`./accounts`|path to storage directory|
|
||||
|
||||
If you are targeting other architectures (like KaiOS or Android), the webserver binary can be cross-compiled easily with [rust-cross](https://github.com/cross-rs/cross):
|
||||
|
||||
```sh
|
||||
cross build --features=webserver --target armv7-linux-androideabi --release
|
||||
```
|
||||
|
||||
#### Using the TypeScript/JavaScript client
|
||||
|
||||
The package includes a JavaScript/TypeScript client which is partially auto-generated through the JSON-RPC library used by this crate ([yerpc](https://github.com/chatmail/yerpc)). Find the source in the [`typescript`](typescript) folder.
|
||||
The package includes a JavaScript/TypeScript client which is partially auto-generated through the JSON-RPC library used by this crate ([yerpc](https://github.com/Frando/yerpc/)). Find the source in the [`typescript`](typescript) folder.
|
||||
|
||||
To use it locally, first install the dependencies and compile the TypeScript code to JavaScript:
|
||||
```sh
|
||||
@@ -22,7 +52,15 @@ npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
The JavaScript client is [published on NPM](https://www.npmjs.com/package/@deltachat/jsonrpc-client).
|
||||
The JavaScript client is not yet published on NPM (but will likely be soon). Currently, it is recommended to vendor the bundled build. After running `npm run build` as documented above, there will be a file `dist/deltachat.bundle.js`. This is an ESM module containing all dependencies. Copy this file to your project and import the DeltaChat class.
|
||||
|
||||
|
||||
```typescript
|
||||
import { DeltaChat } from './deltachat.bundle.js'
|
||||
const dc = new DeltaChat('ws://localhost:20808/ws')
|
||||
const accounts = await dc.rpc.getAllAccounts()
|
||||
console.log('accounts', accounts)
|
||||
```
|
||||
|
||||
A script is included to build autogenerated documentation, which includes all RPC methods:
|
||||
```sh
|
||||
@@ -35,6 +73,18 @@ Then open the [`typescript/docs`](typescript/docs) folder in a web browser.
|
||||
|
||||
#### Running the example app
|
||||
|
||||
We include a small demo web application that talks to the WebSocket server. It can be used for testing. Feel invited to expand this.
|
||||
|
||||
```sh
|
||||
cd typescript
|
||||
npm run build
|
||||
npm run example:build
|
||||
npm run example:start
|
||||
```
|
||||
Then, open [`http://localhost:8080/example.html`](http://localhost:8080/example.html) in a web browser.
|
||||
|
||||
Run `npm run example:dev` to live-rebuild the example app when files changes.
|
||||
|
||||
### Testing
|
||||
|
||||
The crate includes both a basic Rust smoke test and more featureful integration tests that use the TypeScript client.
|
||||
@@ -54,12 +104,14 @@ cd typescript
|
||||
npm run test
|
||||
```
|
||||
|
||||
This will build the `deltachat-jsonrpc-server` binary and then run a test suite.
|
||||
This will build the `deltachat-jsonrpc-server` binary and then run a test suite against the WebSocket server.
|
||||
|
||||
The test suite includes some tests that need online connectivity and a way to create test email accounts. To run these tests, set the `CHATMAIL_DOMAIN` environment variable to your testing email server domain.
|
||||
The test suite includes some tests that need online connectivity and a way to create test email accounts. To run these tests, talk to DeltaChat developers to get a token for the `testrun.org` service, or use a local instance of [`mailadm`](https://github.com/deltachat/docker-mailadm).
|
||||
|
||||
Then, set the `CHATMAIL_DOMAIN` environment variable to your testing email server domain.
|
||||
|
||||
```
|
||||
CHATMAIL_DOMAIN=ci-chatmail.testrun.org npm run test
|
||||
CHATMAIL_DOMAIN=chat.example.org npm run test
|
||||
```
|
||||
|
||||
#### Test Coverage
|
||||
|
||||
28
deltachat-jsonrpc/TODO.md
Normal file
28
deltachat-jsonrpc/TODO.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# TODO
|
||||
|
||||
- [ ] different test type to simulate two devices: to test autocrypt_initiate_key_transfer & autocrypt_continue_key_transfer
|
||||
|
||||
## MVP - Websocket server&client
|
||||
|
||||
For kaiOS and other experiments, like a deltachat "web" over network from an android phone.
|
||||
|
||||
- [ ] coverage for a majority of the API
|
||||
- [ ] Blobs served
|
||||
- [ ] Blob upload (for attachments, setting profile-picture, importing backup and so on)
|
||||
- [ ] other way blobs can be addressed when using websocket vs. jsonrpc over dc-node
|
||||
- [ ] Web push API? At least some kind of notification hook closure this lib can accept.
|
||||
|
||||
### Other Ideas for the Websocket server
|
||||
|
||||
- [ ] make sure there can only be one connection at a time to the ws
|
||||
- why? , it could give problems if its commanded from multiple connections
|
||||
- [ ] encrypted connection?
|
||||
- [ ] authenticated connection?
|
||||
- [ ] Look into unit-testing for the proc macros?
|
||||
- [ ] proc macro taking over doc comments to generated typescript file
|
||||
|
||||
## Desktop Apis
|
||||
|
||||
Incomplete todo for desktop api porting, just some remainders for points that might need more work:
|
||||
|
||||
- [ ] manual start/stop io functions in the api for context and accounts, so "not syncing all accounts" can still be done in desktop -> webserver should then not do start io on all accounts by default
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::str;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -7,7 +7,6 @@ use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
use anyhow::{anyhow, bail, ensure, Context, Result};
|
||||
pub use deltachat::accounts::Accounts;
|
||||
use deltachat::blob::BlobObject;
|
||||
use deltachat::chat::{
|
||||
self, add_contact_to_chat, forward_msgs, get_chat_media, get_chat_msgs, get_chat_msgs_ex,
|
||||
marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
|
||||
@@ -22,7 +21,7 @@ use deltachat::ephemeral::Timer;
|
||||
use deltachat::location;
|
||||
use deltachat::message::get_msg_read_receipts;
|
||||
use deltachat::message::{
|
||||
self, delete_msgs_ex, markseen_msgs, Message, MessageState, MsgId, Viewtype,
|
||||
self, delete_msgs, markseen_msgs, Message, MessageState, MsgId, Viewtype,
|
||||
};
|
||||
use deltachat::peer_channels::{
|
||||
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
|
||||
@@ -39,7 +38,6 @@ use deltachat::{imex, info};
|
||||
use sanitize_filename::is_sanitized;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{watch, Mutex, RwLock};
|
||||
use types::login_param::EnteredLoginParam;
|
||||
use walkdir::WalkDir;
|
||||
use yerpc::rpc;
|
||||
|
||||
@@ -227,9 +225,8 @@ impl CommandApi {
|
||||
/// Get a list of all configured accounts.
|
||||
async fn get_all_accounts(&self) -> Result<Vec<Account>> {
|
||||
let mut accounts = Vec::new();
|
||||
let accounts_lock = self.accounts.read().await;
|
||||
for id in accounts_lock.get_all() {
|
||||
let context_option = accounts_lock.get_account(id);
|
||||
for id in self.accounts.read().await.get_all() {
|
||||
let context_option = self.accounts.read().await.get_account(id);
|
||||
if let Some(ctx) = context_option {
|
||||
accounts.push(Account::from_context(&ctx, id).await?)
|
||||
}
|
||||
@@ -327,12 +324,8 @@ impl CommandApi {
|
||||
.get_config_bool(deltachat::config::Config::ProxyEnabled)
|
||||
.await?;
|
||||
|
||||
let provider_info = get_provider_info(
|
||||
&ctx,
|
||||
email.split('@').next_back().unwrap_or(""),
|
||||
proxy_enabled,
|
||||
)
|
||||
.await;
|
||||
let provider_info =
|
||||
get_provider_info(&ctx, email.split('@').last().unwrap_or(""), proxy_enabled).await;
|
||||
Ok(ProviderInfo::from_dc_type(provider_info))
|
||||
}
|
||||
|
||||
@@ -348,19 +341,11 @@ impl CommandApi {
|
||||
ctx.get_info().await
|
||||
}
|
||||
|
||||
/// Get the blob dir.
|
||||
async fn get_blob_dir(&self, account_id: u32) -> Result<Option<String>> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
Ok(ctx.get_blobdir().to_str().map(|s| s.to_owned()))
|
||||
}
|
||||
|
||||
/// Copy file to blob dir.
|
||||
async fn copy_to_blob_dir(&self, account_id: u32, path: String) -> Result<PathBuf> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let file = Path::new(&path);
|
||||
Ok(BlobObject::create_and_deduplicate(&ctx, file, file)?.to_abs_path())
|
||||
}
|
||||
|
||||
async fn draft_self_report(&self, account_id: u32) -> Result<u32> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
Ok(ctx.draft_self_report().await?.to_u32())
|
||||
@@ -437,9 +422,6 @@ impl CommandApi {
|
||||
|
||||
/// Configures this account with the currently set parameters.
|
||||
/// Setup the credential config before calling this.
|
||||
///
|
||||
/// Deprecated as of 2025-02; use `add_transport_from_qr()`
|
||||
/// or `add_or_update_transport()` instead.
|
||||
async fn configure(&self, account_id: u32) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
ctx.stop_io().await;
|
||||
@@ -454,78 +436,6 @@ impl CommandApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configures a new email account using the provided parameters
|
||||
/// and adds it as a transport.
|
||||
///
|
||||
/// If the email address is the same as an existing transport,
|
||||
/// then this existing account will be reconfigured instead of a new one being added.
|
||||
///
|
||||
/// This function stops and starts IO as needed.
|
||||
///
|
||||
/// Usually it will be enough to only set `addr` and `password`,
|
||||
/// and all the other settings will be autoconfigured.
|
||||
///
|
||||
/// During configuration, ConfigureProgress events are emitted;
|
||||
/// they indicate a successful configuration as well as errors
|
||||
/// and may be used to create a progress bar.
|
||||
/// This function will return after configuration is finished.
|
||||
///
|
||||
/// If configuration is successful,
|
||||
/// the working server parameters will be saved
|
||||
/// and used for connecting to the server.
|
||||
/// The parameters entered by the user will be saved separately
|
||||
/// so that they can be prefilled when the user opens the server-configuration screen again.
|
||||
///
|
||||
/// See also:
|
||||
/// - [Self::is_configured()] to check whether there is
|
||||
/// at least one working transport.
|
||||
/// - [Self::add_transport_from_qr()] to add a transport
|
||||
/// from a server encoded in a QR code.
|
||||
/// - [Self::list_transports()] to get a list of all configured transports.
|
||||
/// - [Self::delete_transport()] to remove a transport.
|
||||
async fn add_or_update_transport(
|
||||
&self,
|
||||
account_id: u32,
|
||||
param: EnteredLoginParam,
|
||||
) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
ctx.add_or_update_transport(&mut param.try_into()?).await
|
||||
}
|
||||
|
||||
/// Deprecated 2025-04. Alias for [Self::add_or_update_transport()].
|
||||
async fn add_transport(&self, account_id: u32, param: EnteredLoginParam) -> Result<()> {
|
||||
self.add_or_update_transport(account_id, param).await
|
||||
}
|
||||
|
||||
/// Adds a new email account as a transport
|
||||
/// using the server encoded in the QR code.
|
||||
/// See [Self::add_or_update_transport].
|
||||
async fn add_transport_from_qr(&self, account_id: u32, qr: String) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
ctx.add_transport_from_qr(&qr).await
|
||||
}
|
||||
|
||||
/// Returns the list of all email accounts that are used as a transport in the current profile.
|
||||
/// Use [Self::add_or_update_transport()] to add or change a transport
|
||||
/// and [Self::delete_transport()] to delete a transport.
|
||||
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let res = ctx
|
||||
.list_transports()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|t| t.into())
|
||||
.collect();
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Removes the transport with the specified email address
|
||||
/// (i.e. [EnteredLoginParam::addr]).
|
||||
async fn delete_transport(&self, account_id: u32, addr: String) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
ctx.delete_transport(&addr).await
|
||||
}
|
||||
|
||||
/// Signal an ongoing process to stop.
|
||||
async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
@@ -1295,15 +1205,7 @@ impl CommandApi {
|
||||
async fn delete_messages(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let msgs: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect();
|
||||
delete_msgs_ex(&ctx, &msgs, false).await
|
||||
}
|
||||
|
||||
/// Delete messages. The messages are deleted on the current device,
|
||||
/// on the IMAP server and also for all chat members
|
||||
async fn delete_messages_for_all(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let msgs: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect();
|
||||
delete_msgs_ex(&ctx, &msgs, true).await
|
||||
delete_msgs(&ctx, &msgs).await
|
||||
}
|
||||
|
||||
/// Get an informational text for a single message. The text is multiline and may
|
||||
@@ -1551,7 +1453,6 @@ impl CommandApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets display name for existing contact.
|
||||
async fn change_contact_name(
|
||||
&self,
|
||||
account_id: u32,
|
||||
@@ -1560,7 +1461,9 @@ impl CommandApi {
|
||||
) -> Result<()> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let contact_id = ContactId::new(contact_id);
|
||||
contact_id.set_name(&ctx, &name).await?;
|
||||
let contact = Contact::get_by_id(&ctx, contact_id).await?;
|
||||
let addr = contact.get_addr();
|
||||
Contact::create(&ctx, &name, addr).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1960,9 +1863,13 @@ impl CommandApi {
|
||||
|
||||
/// Get href from a WebxdcInfoMessage which might include a hash holding
|
||||
/// information about a specific position or state in a webxdc app (optional)
|
||||
async fn get_webxdc_href(&self, account_id: u32, info_msg_id: u32) -> Result<Option<String>> {
|
||||
async fn get_webxdc_href(
|
||||
&self,
|
||||
account_id: u32,
|
||||
instance_msg_id: u32,
|
||||
) -> Result<Option<String>> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
let message = Message::load_from_db(&ctx, MsgId::new(info_msg_id)).await?;
|
||||
let message = Message::load_from_db(&ctx, MsgId::new(instance_msg_id)).await?;
|
||||
Ok(message.get_webxdc_href())
|
||||
}
|
||||
|
||||
@@ -2285,37 +2192,6 @@ impl CommandApi {
|
||||
|
||||
// mimics the old desktop call, will get replaced with something better in the composer rewrite,
|
||||
// the better version will just be sending the current draft, though there will be probably something similar with more options to this for the corner cases like setting a marker on the map
|
||||
/// Send a message to a chat.
|
||||
///
|
||||
/// This function returns after the message has been placed in the sending queue.
|
||||
/// This does not imply that the message was really sent out yet.
|
||||
/// However, from your view, you're done with the message.
|
||||
/// Sooner or later it will find its way.
|
||||
///
|
||||
/// **Attaching files:**
|
||||
///
|
||||
/// Pass the file path in the `file` parameter.
|
||||
/// If `file` is not in the blob directory yet,
|
||||
/// it will be copied into the blob directory.
|
||||
/// If you want, you can delete the file immediately after this function returns.
|
||||
///
|
||||
/// You can also write the attachment directly into the blob directory
|
||||
/// and then pass the path as the `file` parameter;
|
||||
/// this will prevent an unnecessary copying of the file.
|
||||
///
|
||||
/// In `filename`, you can pass the original name of the file,
|
||||
/// which will then be shown in the UI.
|
||||
/// in this case the current name of `file` on the filesystem will be ignored.
|
||||
///
|
||||
/// In order to deduplicate files that contain the same data,
|
||||
/// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
|
||||
///
|
||||
/// NOTE:
|
||||
/// - This function will rename the file. To get the new file path, call `get_file()`.
|
||||
/// - The file must not be modified after this function was called.
|
||||
/// - Images etc. will NOT be recoded.
|
||||
/// In order to recode images,
|
||||
/// use `misc_set_draft` and pass `Image` as the viewtype.
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
async fn misc_send_msg(
|
||||
&self,
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use deltachat::login_param as dc;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use yerpc::TypeDef;
|
||||
|
||||
/// Login parameters entered by the user.
|
||||
///
|
||||
/// Usually it will be enough to only set `addr` and `password`,
|
||||
/// and all the other settings will be autoconfigured.
|
||||
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnteredLoginParam {
|
||||
/// Email address.
|
||||
pub addr: String,
|
||||
|
||||
/// Password.
|
||||
pub password: String,
|
||||
|
||||
/// Imap server hostname or IP address.
|
||||
pub imap_server: Option<String>,
|
||||
|
||||
/// Imap server port.
|
||||
pub imap_port: Option<u16>,
|
||||
|
||||
/// Imap socket security.
|
||||
pub imap_security: Option<Socket>,
|
||||
|
||||
/// Imap username.
|
||||
pub imap_user: Option<String>,
|
||||
|
||||
/// SMTP server hostname or IP address.
|
||||
pub smtp_server: Option<String>,
|
||||
|
||||
/// SMTP server port.
|
||||
pub smtp_port: Option<u16>,
|
||||
|
||||
/// SMTP socket security.
|
||||
pub smtp_security: Option<Socket>,
|
||||
|
||||
/// SMTP username.
|
||||
pub smtp_user: Option<String>,
|
||||
|
||||
/// SMTP Password.
|
||||
///
|
||||
/// Only needs to be specified if different than IMAP password.
|
||||
pub smtp_password: Option<String>,
|
||||
|
||||
/// TLS options: whether to allow invalid certificates and/or
|
||||
/// invalid hostnames.
|
||||
/// Default: Automatic
|
||||
pub certificate_checks: Option<EnteredCertificateChecks>,
|
||||
|
||||
/// If true, login via OAUTH2 (not recommended anymore).
|
||||
/// Default: false
|
||||
pub oauth2: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<dc::EnteredLoginParam> for EnteredLoginParam {
|
||||
fn from(param: dc::EnteredLoginParam) -> Self {
|
||||
let imap_security: Socket = param.imap.security.into();
|
||||
let smtp_security: Socket = param.smtp.security.into();
|
||||
let certificate_checks: EnteredCertificateChecks = param.certificate_checks.into();
|
||||
Self {
|
||||
addr: param.addr,
|
||||
password: param.imap.password,
|
||||
imap_server: param.imap.server.into_option(),
|
||||
imap_port: param.imap.port.into_option(),
|
||||
imap_security: imap_security.into_option(),
|
||||
imap_user: param.imap.user.into_option(),
|
||||
smtp_server: param.smtp.server.into_option(),
|
||||
smtp_port: param.smtp.port.into_option(),
|
||||
smtp_security: smtp_security.into_option(),
|
||||
smtp_user: param.smtp.user.into_option(),
|
||||
smtp_password: param.smtp.password.into_option(),
|
||||
certificate_checks: certificate_checks.into_option(),
|
||||
oauth2: param.oauth2.into_option(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(param: EnteredLoginParam) -> Result<Self> {
|
||||
Ok(Self {
|
||||
addr: param.addr,
|
||||
imap: dc::EnteredServerLoginParam {
|
||||
server: param.imap_server.unwrap_or_default(),
|
||||
port: param.imap_port.unwrap_or_default(),
|
||||
security: param.imap_security.unwrap_or_default().into(),
|
||||
user: param.imap_user.unwrap_or_default(),
|
||||
password: param.password,
|
||||
},
|
||||
smtp: dc::EnteredServerLoginParam {
|
||||
server: param.smtp_server.unwrap_or_default(),
|
||||
port: param.smtp_port.unwrap_or_default(),
|
||||
security: param.smtp_security.unwrap_or_default().into(),
|
||||
user: param.smtp_user.unwrap_or_default(),
|
||||
password: param.smtp_password.unwrap_or_default(),
|
||||
},
|
||||
certificate_checks: param.certificate_checks.unwrap_or_default().into(),
|
||||
oauth2: param.oauth2.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema, Default, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Socket {
|
||||
/// Unspecified socket security, select automatically.
|
||||
#[default]
|
||||
Automatic,
|
||||
|
||||
/// TLS connection.
|
||||
Ssl,
|
||||
|
||||
/// STARTTLS connection.
|
||||
Starttls,
|
||||
|
||||
/// No TLS, plaintext connection.
|
||||
Plain,
|
||||
}
|
||||
|
||||
impl From<dc::Socket> for Socket {
|
||||
fn from(value: dc::Socket) -> Self {
|
||||
match value {
|
||||
dc::Socket::Automatic => Self::Automatic,
|
||||
dc::Socket::Ssl => Self::Ssl,
|
||||
dc::Socket::Starttls => Self::Starttls,
|
||||
dc::Socket::Plain => Self::Plain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Socket> for dc::Socket {
|
||||
fn from(value: Socket) -> Self {
|
||||
match value {
|
||||
Socket::Automatic => Self::Automatic,
|
||||
Socket::Ssl => Self::Ssl,
|
||||
Socket::Starttls => Self::Starttls,
|
||||
Socket::Plain => Self::Plain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema, Default, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EnteredCertificateChecks {
|
||||
/// `Automatic` means that provider database setting should be taken.
|
||||
/// If there is no provider database setting for certificate checks,
|
||||
/// check certificates strictly.
|
||||
#[default]
|
||||
Automatic,
|
||||
|
||||
/// Ensure that TLS certificate is valid for the server hostname.
|
||||
Strict,
|
||||
|
||||
/// Accept certificates that are expired, self-signed
|
||||
/// or otherwise not valid for the server hostname.
|
||||
AcceptInvalidCertificates,
|
||||
}
|
||||
|
||||
impl From<dc::EnteredCertificateChecks> for EnteredCertificateChecks {
|
||||
fn from(value: dc::EnteredCertificateChecks) -> Self {
|
||||
match value {
|
||||
dc::EnteredCertificateChecks::Automatic => Self::Automatic,
|
||||
dc::EnteredCertificateChecks::Strict => Self::Strict,
|
||||
dc::EnteredCertificateChecks::AcceptInvalidCertificates => {
|
||||
Self::AcceptInvalidCertificates
|
||||
}
|
||||
dc::EnteredCertificateChecks::AcceptInvalidCertificates2 => {
|
||||
Self::AcceptInvalidCertificates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EnteredCertificateChecks> for dc::EnteredCertificateChecks {
|
||||
fn from(value: EnteredCertificateChecks) -> Self {
|
||||
match value {
|
||||
EnteredCertificateChecks::Automatic => Self::Automatic,
|
||||
EnteredCertificateChecks::Strict => Self::Strict,
|
||||
EnteredCertificateChecks::AcceptInvalidCertificates => Self::AcceptInvalidCertificates,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait IntoOption<T> {
|
||||
fn into_option(self) -> Option<T>;
|
||||
}
|
||||
impl<T> IntoOption<T> for T
|
||||
where
|
||||
T: Default + std::cmp::PartialEq,
|
||||
{
|
||||
fn into_option(self) -> Option<T> {
|
||||
if self == T::default() {
|
||||
None
|
||||
} else {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,9 +70,6 @@ pub struct MessageObject {
|
||||
/// when is_info is true this describes what type of system message it is
|
||||
system_message_type: SystemMessageType,
|
||||
|
||||
/// if is_info is set, this refers to the contact profile that should be opened when the info message is tapped.
|
||||
info_contact_id: Option<u32>,
|
||||
|
||||
duration: i32,
|
||||
dimensions_height: i32,
|
||||
dimensions_width: i32,
|
||||
@@ -231,10 +228,6 @@ impl MessageObject {
|
||||
is_forwarded: message.is_forwarded(),
|
||||
is_bot: message.is_bot(),
|
||||
system_message_type: message.get_info_type().into(),
|
||||
info_contact_id: message
|
||||
.get_info_contact_id(context)
|
||||
.await?
|
||||
.map(|id| id.to_u32()),
|
||||
|
||||
duration: message.get_duration(),
|
||||
dimensions_height: message.get_height(),
|
||||
@@ -680,6 +673,7 @@ pub struct MessageReadReceipt {
|
||||
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MessageInfo {
|
||||
rawtext: String,
|
||||
ephemeral_timer: EphemeralTimer,
|
||||
/// When message is ephemeral this contains the timestamp of the message expiry
|
||||
ephemeral_timestamp: Option<i64>,
|
||||
@@ -692,6 +686,7 @@ pub struct MessageInfo {
|
||||
impl MessageInfo {
|
||||
pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<Self> {
|
||||
let message = Message::load_from_db(context, msg_id).await?;
|
||||
let rawtext = msg_id.rawtext(context).await?;
|
||||
let ephemeral_timer = message.get_ephemeral_timer().into();
|
||||
let ephemeral_timestamp = match message.get_ephemeral_timer() {
|
||||
deltachat::ephemeral::Timer::Disabled => None,
|
||||
@@ -704,6 +699,7 @@ impl MessageInfo {
|
||||
let hop_info = msg_id.hop_info(context).await?;
|
||||
|
||||
Ok(Self {
|
||||
rawtext,
|
||||
ephemeral_timer,
|
||||
ephemeral_timestamp,
|
||||
error: message.error(),
|
||||
|
||||
@@ -5,7 +5,6 @@ pub mod contact;
|
||||
pub mod events;
|
||||
pub mod http;
|
||||
pub mod location;
|
||||
pub mod login_param;
|
||||
pub mod message;
|
||||
pub mod provider_info;
|
||||
pub mod qr;
|
||||
|
||||
47
deltachat-jsonrpc/src/webserver.rs
Normal file
47
deltachat-jsonrpc/src/webserver.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
#![recursion_limit = "256"]
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use axum::{extract::ws::WebSocketUpgrade, response::Response, routing::get, Extension, Router};
|
||||
use yerpc::axum::handle_ws_rpc;
|
||||
use yerpc::{RpcClient, RpcSession};
|
||||
|
||||
mod api;
|
||||
use api::{Accounts, CommandApi};
|
||||
|
||||
const DEFAULT_PORT: u16 = 20808;
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let path = std::env::var("DC_ACCOUNTS_PATH").unwrap_or_else(|_| "./accounts".to_string());
|
||||
let port = std::env::var("DC_PORT")
|
||||
.map(|port| port.parse::<u16>().expect("DC_PORT must be a number"))
|
||||
.unwrap_or(DEFAULT_PORT);
|
||||
log::info!("Starting with accounts directory `{path}`.");
|
||||
let writable = true;
|
||||
let accounts = Accounts::new(PathBuf::from(&path), writable).await.unwrap();
|
||||
let state = CommandApi::new(accounts);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws", get(handler))
|
||||
.layer(Extension(state.clone()));
|
||||
|
||||
tokio::spawn(async move {
|
||||
state.accounts.write().await.start_io().await;
|
||||
});
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
log::info!("JSON-RPC WebSocket server listening on {}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handler(ws: WebSocketUpgrade, Extension(api): Extension<CommandApi>) -> Response {
|
||||
let (client, out_receiver) = RpcClient::new();
|
||||
let session = RpcSession::new(client.clone(), api.clone());
|
||||
handle_ws_rpc(ws, out_receiver, session).await
|
||||
}
|
||||
56
deltachat-jsonrpc/typescript/example.html
Normal file
56
deltachat-jsonrpc/typescript/example.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>DeltaChat JSON-RPC example</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: monospace;
|
||||
background: black;
|
||||
color: grey;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr;
|
||||
grid-template-areas: "a a" "b c";
|
||||
}
|
||||
.message {
|
||||
color: red;
|
||||
}
|
||||
#header {
|
||||
grid-area: a;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
#header a {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
#main {
|
||||
grid-area: b;
|
||||
color: green;
|
||||
}
|
||||
#main h2,
|
||||
#main h3 {
|
||||
color: blue;
|
||||
}
|
||||
#side {
|
||||
grid-area: c;
|
||||
color: #777;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="dist/example.bundle.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>DeltaChat JSON-RPC example</h1>
|
||||
<div class="grid">
|
||||
<div id="header"></div>
|
||||
<div id="main"></div>
|
||||
<div id="side"><h2>log</h2></div>
|
||||
</div>
|
||||
<p>
|
||||
Tip: open the dev console and use the client with
|
||||
<code>window.client</code>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
109
deltachat-jsonrpc/typescript/example/example.ts
Normal file
109
deltachat-jsonrpc/typescript/example/example.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { DcEvent, DeltaChat } from "../deltachat.js";
|
||||
|
||||
var SELECTED_ACCOUNT = 0;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", (_event) => {
|
||||
(window as any).selectDeltaAccount = (id: string) => {
|
||||
SELECTED_ACCOUNT = Number(id);
|
||||
window.dispatchEvent(new Event("account-changed"));
|
||||
};
|
||||
console.log("launch run script...");
|
||||
run().catch((err) => console.error("run failed", err));
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const $main = document.getElementById("main")!;
|
||||
const $side = document.getElementById("side")!;
|
||||
const $head = document.getElementById("header")!;
|
||||
|
||||
const client = new DeltaChat("ws://localhost:20808/ws");
|
||||
|
||||
(window as any).client = client.rpc;
|
||||
|
||||
client.on("ALL", (accountId, event) => {
|
||||
onIncomingEvent(accountId, event);
|
||||
});
|
||||
|
||||
window.addEventListener("account-changed", async (_event: Event) => {
|
||||
listChatsForSelectedAccount();
|
||||
});
|
||||
|
||||
await Promise.all([loadAccountsInHeader(), listChatsForSelectedAccount()]);
|
||||
|
||||
async function loadAccountsInHeader() {
|
||||
console.log("load accounts");
|
||||
const accounts = await client.rpc.getAllAccounts();
|
||||
console.log("accounts loaded", accounts);
|
||||
for (const account of accounts) {
|
||||
if (account.kind === "Configured") {
|
||||
write(
|
||||
$head,
|
||||
`<a href="#" onclick="selectDeltaAccount(${account.id})">
|
||||
${account.id}: ${account.addr!}
|
||||
</a> `
|
||||
);
|
||||
} else {
|
||||
write(
|
||||
$head,
|
||||
`<a href="#">
|
||||
${account.id}: (unconfigured)
|
||||
</a> `
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listChatsForSelectedAccount() {
|
||||
clear($main);
|
||||
const selectedAccount = SELECTED_ACCOUNT;
|
||||
const info = await client.rpc.getAccountInfo(selectedAccount);
|
||||
if (info.kind !== "Configured") {
|
||||
return write($main, "Account is not configured");
|
||||
}
|
||||
write($main, `<h2>${info.addr!}</h2>`);
|
||||
const chats = await client.rpc.getChatlistEntries(
|
||||
selectedAccount,
|
||||
0,
|
||||
null,
|
||||
null
|
||||
);
|
||||
for (const chatId of chats) {
|
||||
const chat = await client.rpc.getFullChatById(selectedAccount, chatId);
|
||||
write($main, `<h3>${chat.name}</h3>`);
|
||||
const messageIds = await client.rpc.getMessageIds(
|
||||
selectedAccount,
|
||||
chatId,
|
||||
false,
|
||||
false
|
||||
);
|
||||
const messages = await client.rpc.getMessages(
|
||||
selectedAccount,
|
||||
messageIds
|
||||
);
|
||||
for (const [_messageId, message] of Object.entries(messages)) {
|
||||
if (message.kind === "message") write($main, `<p>${message.text}</p>`);
|
||||
else write($main, `<p>loading error: ${message.error}</p>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onIncomingEvent(accountId: number, event: DcEvent) {
|
||||
write(
|
||||
$side,
|
||||
`
|
||||
<p class="message">
|
||||
[<strong>${event.kind}</strong> on account ${accountId}]<br>
|
||||
<em>f1:</em> ${JSON.stringify(
|
||||
Object.assign({}, event, { kind: undefined })
|
||||
)}
|
||||
</p>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function write(el: HTMLElement, html: string) {
|
||||
el.innerHTML += html;
|
||||
}
|
||||
function clear(el: HTMLElement) {
|
||||
el.innerHTML = "";
|
||||
}
|
||||
29
deltachat-jsonrpc/typescript/example/node-add-account.js
Normal file
29
deltachat-jsonrpc/typescript/example/node-add-account.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { DeltaChat } from "../dist/deltachat.js";
|
||||
|
||||
run().catch(console.error);
|
||||
|
||||
async function run() {
|
||||
const delta = new DeltaChat("ws://localhost:20808/ws");
|
||||
delta.on("event", (event) => {
|
||||
console.log("event", event.data);
|
||||
});
|
||||
|
||||
const email = process.argv[2];
|
||||
const password = process.argv[3];
|
||||
if (!email || !password)
|
||||
throw new Error(
|
||||
"USAGE: node node-add-account.js <EMAILADDRESS> <PASSWORD>"
|
||||
);
|
||||
console.log(`creating account for ${email}`);
|
||||
const id = await delta.rpc.addAccount();
|
||||
console.log(`created account id ${id}`);
|
||||
await delta.rpc.setConfig(id, "addr", email);
|
||||
await delta.rpc.setConfig(id, "mail_pw", password);
|
||||
console.log("configuration updated");
|
||||
await delta.rpc.configure(id);
|
||||
console.log("account configured!");
|
||||
|
||||
const accounts = await delta.rpc.getAllAccounts();
|
||||
console.log("accounts", accounts);
|
||||
console.log("waiting for events...");
|
||||
}
|
||||
14
deltachat-jsonrpc/typescript/example/node-demo.js
Normal file
14
deltachat-jsonrpc/typescript/example/node-demo.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { DeltaChat } from "../dist/deltachat.js";
|
||||
|
||||
run().catch(console.error);
|
||||
|
||||
async function run() {
|
||||
const delta = new DeltaChat();
|
||||
delta.on("event", (event) => {
|
||||
console.log("event", event.data);
|
||||
});
|
||||
|
||||
const accounts = await delta.rpc.getAllAccounts();
|
||||
console.log("accounts", accounts);
|
||||
console.log("waiting for events...");
|
||||
}
|
||||
@@ -42,6 +42,10 @@
|
||||
"build:cjs": "esbuild --format=cjs --bundle --packages=external dist/deltachat.js --outfile=dist/deltachat.cjs",
|
||||
"build:tsc": "tsc",
|
||||
"docs": "typedoc --out docs deltachat.ts",
|
||||
"example": "run-s build example:build example:start",
|
||||
"example:build": "esbuild --bundle dist/example/example.js --outfile=dist/example.bundle.js",
|
||||
"example:dev": "esbuild example/example.ts --bundle --outfile=dist/example.bundle.js --servedir=.",
|
||||
"example:start": "http-server .",
|
||||
"extract-constants": "node ./scripts/generate-constants.js",
|
||||
"generate-bindings": "cargo test",
|
||||
"prettier:check": "prettier --check .",
|
||||
@@ -54,5 +58,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "1.159.3"
|
||||
"version": "1.157.1"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as T from "../generated/types.js";
|
||||
import { EventType } from "../generated/types.js";
|
||||
import * as RPC from "../generated/jsonrpc.js";
|
||||
import { RawClient } from "../generated/client.js";
|
||||
import { BaseTransport, Request } from "yerpc";
|
||||
import { WebsocketTransport, BaseTransport, Request } from "yerpc";
|
||||
import { TinyEmitter } from "@deltachat/tiny-emitter";
|
||||
|
||||
type Events = { ALL: (accountId: number, event: EventType) => void } & {
|
||||
@@ -74,6 +74,34 @@ export class BaseDeltaChat<
|
||||
}
|
||||
}
|
||||
|
||||
export type Opts = {
|
||||
url: string;
|
||||
startEventLoop: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_OPTS: Opts = {
|
||||
url: "ws://localhost:20808/ws",
|
||||
startEventLoop: true,
|
||||
};
|
||||
export class DeltaChat extends BaseDeltaChat<WebsocketTransport> {
|
||||
opts: Opts;
|
||||
close() {
|
||||
this.transport.close();
|
||||
}
|
||||
constructor(opts?: Opts | string) {
|
||||
if (typeof opts === "string") {
|
||||
opts = { ...DEFAULT_OPTS, url: opts };
|
||||
} else if (opts) {
|
||||
opts = { ...DEFAULT_OPTS, ...opts };
|
||||
} else {
|
||||
opts = { ...DEFAULT_OPTS };
|
||||
}
|
||||
const transport = new WebsocketTransport(opts.url);
|
||||
super(transport, opts.startEventLoop);
|
||||
this.opts = opts;
|
||||
}
|
||||
}
|
||||
|
||||
export class StdioDeltaChat extends BaseDeltaChat<StdioTransport> {
|
||||
close() {}
|
||||
constructor(input: any, output: any, startEventLoop: boolean) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { assert, expect } from "chai";
|
||||
import { StdioDeltaChat as DeltaChat, DcEvent, C } from "../deltachat.js";
|
||||
import { StdioDeltaChat as DeltaChat, DcEvent } from "../deltachat.js";
|
||||
import { RpcServerHandle, createTempUser, startServer } from "./test_base.js";
|
||||
|
||||
const EVENT_TIMEOUT = 20000;
|
||||
@@ -80,8 +80,11 @@ describe("online tests", function () {
|
||||
}
|
||||
this.timeout(15000);
|
||||
|
||||
const vcard = await dc.rpc.makeVcard(accountId2, [C.DC_CONTACT_ID_SELF]);
|
||||
const contactId = (await dc.rpc.importVcardContents(accountId1, vcard))[0];
|
||||
const contactId = await dc.rpc.createContact(
|
||||
accountId1,
|
||||
account2.email,
|
||||
null
|
||||
);
|
||||
const chatId = await dc.rpc.createChatByContactId(accountId1, contactId);
|
||||
const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2);
|
||||
|
||||
@@ -98,18 +101,20 @@ describe("online tests", function () {
|
||||
expect(messageList).have.length(1);
|
||||
const message = await dc.rpc.getMessage(accountId2, messageList[0]);
|
||||
expect(message.text).equal("Hello");
|
||||
expect(message.showPadlock).equal(true);
|
||||
});
|
||||
|
||||
it("send and receive text message roundtrip", async function () {
|
||||
it("send and receive text message roundtrip, encrypted on answer onwards", async function () {
|
||||
if (!accountsConfigured) {
|
||||
this.skip();
|
||||
}
|
||||
this.timeout(10000);
|
||||
|
||||
// send message from A to B
|
||||
const vcard = await dc.rpc.makeVcard(accountId2, [C.DC_CONTACT_ID_SELF]);
|
||||
const contactId = (await dc.rpc.importVcardContents(accountId1, vcard))[0];
|
||||
const contactId = await dc.rpc.createContact(
|
||||
accountId1,
|
||||
account2.email,
|
||||
null
|
||||
);
|
||||
const chatId = await dc.rpc.createChatByContactId(accountId1, contactId);
|
||||
const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2);
|
||||
dc.rpc.miscSendTextMessage(accountId1, chatId, "Hello2");
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
"noImplicitAny": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["*.ts", "test/*.ts"],
|
||||
"include": ["*.ts", "example/*.ts", "test/*.ts"],
|
||||
"compileOnSave": false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "1.159.3"
|
||||
version = "1.157.1"
|
||||
license = "MPL-2.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
@@ -323,7 +323,7 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
|
||||
}
|
||||
});
|
||||
|
||||
println!("Chatmail is awaiting your commands.");
|
||||
println!("Delta Chat Core is awaiting your commands.");
|
||||
|
||||
let config = Config::builder()
|
||||
.history_ignore_space(true)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat-rpc-client"
|
||||
version = "1.159.3"
|
||||
version = "1.157.1"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -70,11 +70,3 @@ line-length = 120
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"imap-tools",
|
||||
"pytest",
|
||||
"pytest-timeout",
|
||||
"pytest-xdist",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from threading import Thread
|
||||
@@ -90,8 +89,8 @@ def _run_cli(
|
||||
help="accounts folder (default: current working directory)",
|
||||
nargs="?",
|
||||
)
|
||||
parser.add_argument("--email", action="store", help="email address", default=os.getenv("DELTACHAT_EMAIL"))
|
||||
parser.add_argument("--password", action="store", help="password", default=os.getenv("DELTACHAT_PASSWORD"))
|
||||
parser.add_argument("--email", action="store", help="email address")
|
||||
parser.add_argument("--password", action="store", help="password")
|
||||
args = parser.parse_args(argv[1:])
|
||||
|
||||
with Rpc(accounts_dir=args.accounts_dir, **kwargs) as rpc:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import TYPE_CHECKING, Optional, Union
|
||||
from warnings import warn
|
||||
|
||||
@@ -26,12 +28,9 @@ class Account:
|
||||
def _rpc(self) -> "Rpc":
|
||||
return self.manager.rpc
|
||||
|
||||
def wait_for_event(self, event_type=None) -> AttrDict:
|
||||
def wait_for_event(self) -> AttrDict:
|
||||
"""Wait until the next event and return it."""
|
||||
while True:
|
||||
next_event = AttrDict(self._rpc.wait_for_event(self.id))
|
||||
if event_type is None or next_event.kind == event_type:
|
||||
return next_event
|
||||
return AttrDict(self._rpc.wait_for_event(self.id))
|
||||
|
||||
def clear_all_events(self):
|
||||
"""Removes all queued-up events for a given account. Useful for tests."""
|
||||
@@ -42,14 +41,14 @@ class Account:
|
||||
self._rpc.remove_account(self.id)
|
||||
|
||||
def clone(self) -> "Account":
|
||||
"""Clone given account.
|
||||
This uses backup-transfer via iroh, i.e. the 'Add second device' feature."""
|
||||
future = self._rpc.provide_backup.future(self.id)
|
||||
qr = self._rpc.get_backup_qr(self.id)
|
||||
new_account = self.manager.add_account()
|
||||
new_account._rpc.get_backup(new_account.id, qr)
|
||||
future()
|
||||
return new_account
|
||||
"""Clone given account."""
|
||||
with TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
self.export_backup(tmp_path)
|
||||
files = list(tmp_path.glob("*.tar"))
|
||||
new_account = self.manager.add_account()
|
||||
new_account.import_backup(files[0])
|
||||
return new_account
|
||||
|
||||
def start_io(self) -> None:
|
||||
"""Start the account I/O."""
|
||||
@@ -110,23 +109,15 @@ class Account:
|
||||
"""Configure an account."""
|
||||
yield self._rpc.configure.future(self.id)
|
||||
|
||||
@futuremethod
|
||||
def add_or_update_transport(self, params):
|
||||
"""Add a new transport."""
|
||||
yield self._rpc.add_or_update_transport.future(self.id, params)
|
||||
|
||||
@futuremethod
|
||||
def list_transports(self):
|
||||
"""Returns the list of all email accounts that are used as a transport in the current profile."""
|
||||
transports = yield self._rpc.list_transports.future(self.id)
|
||||
return transports
|
||||
|
||||
def bring_online(self):
|
||||
"""Start I/O and wait until IMAP becomes IDLE."""
|
||||
self.start_io()
|
||||
self.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
while True:
|
||||
event = self.wait_for_event()
|
||||
if event.kind == EventType.IMAP_INBOX_IDLE:
|
||||
break
|
||||
|
||||
def create_contact(self, obj: Union[int, str, Contact, "Account"], name: Optional[str] = None) -> Contact:
|
||||
def create_contact(self, obj: Union[int, str, Contact], name: Optional[str] = None) -> Contact:
|
||||
"""Create a new Contact or return an existing one.
|
||||
|
||||
Calling this method will always result in the same
|
||||
@@ -134,15 +125,9 @@ class Account:
|
||||
with that e-mail address, it is unblocked and its display
|
||||
name is updated if specified.
|
||||
|
||||
:param obj: email-address, contact id or account.
|
||||
:param obj: email-address or contact id.
|
||||
:param name: (optional) display name for this contact.
|
||||
"""
|
||||
if isinstance(obj, Account):
|
||||
vcard = obj.self_contact.make_vcard()
|
||||
[contact] = self.import_vcard(vcard)
|
||||
if name:
|
||||
contact.set_name(name)
|
||||
return contact
|
||||
if isinstance(obj, int):
|
||||
obj = Contact(self, obj)
|
||||
if isinstance(obj, Contact):
|
||||
@@ -163,8 +148,9 @@ class Account:
|
||||
return [Contact(self, contact_id) for contact_id in contact_ids]
|
||||
|
||||
def create_chat(self, account: "Account") -> Chat:
|
||||
"""Create a 1:1 chat with another account."""
|
||||
return self.create_contact(account).create_chat()
|
||||
vcard = account.self_contact.make_vcard()
|
||||
[contact] = self.import_vcard(vcard)
|
||||
return contact.create_chat()
|
||||
|
||||
def get_device_chat(self) -> Chat:
|
||||
"""Return device chat."""
|
||||
@@ -348,15 +334,24 @@ class Account:
|
||||
|
||||
def wait_for_incoming_msg_event(self):
|
||||
"""Wait for incoming message event and return it."""
|
||||
return self.wait_for_event(EventType.INCOMING_MSG)
|
||||
while True:
|
||||
event = self.wait_for_event()
|
||||
if event.kind == EventType.INCOMING_MSG:
|
||||
return event
|
||||
|
||||
def wait_for_msgs_changed_event(self):
|
||||
"""Wait for messages changed event and return it."""
|
||||
return self.wait_for_event(EventType.MSGS_CHANGED)
|
||||
while True:
|
||||
event = self.wait_for_event()
|
||||
if event.kind == EventType.MSGS_CHANGED:
|
||||
return event
|
||||
|
||||
def wait_for_msgs_noticed_event(self):
|
||||
"""Wait for messages noticed event and return it."""
|
||||
return self.wait_for_event(EventType.MSGS_NOTICED)
|
||||
while True:
|
||||
event = self.wait_for_event()
|
||||
if event.kind == EventType.MSGS_NOTICED:
|
||||
return event
|
||||
|
||||
def wait_for_incoming_msg(self):
|
||||
"""Wait for incoming message and return it.
|
||||
@@ -377,7 +372,10 @@ class Account:
|
||||
break
|
||||
|
||||
def wait_for_reactions_changed(self):
|
||||
return self.wait_for_event(EventType.REACTIONS_CHANGED)
|
||||
while True:
|
||||
event = self.wait_for_event()
|
||||
if event.kind == EventType.REACTIONS_CHANGED:
|
||||
return event
|
||||
|
||||
def get_fresh_messages_in_arrival_order(self) -> list[Message]:
|
||||
"""Return fresh messages list sorted in the order of their arrival, with ascending IDs."""
|
||||
|
||||
@@ -48,7 +48,6 @@ class EventType(str, Enum):
|
||||
MSG_READ = "MsgRead"
|
||||
MSG_DELETED = "MsgDeleted"
|
||||
CHAT_MODIFIED = "ChatModified"
|
||||
CHAT_DELETED = "ChatDeleted"
|
||||
CHAT_EPHEMERAL_TIMER_MODIFIED = "ChatEphemeralTimerModified"
|
||||
CONTACTS_CHANGED = "ContactsChanged"
|
||||
LOCATION_CHANGED = "LocationChanged"
|
||||
|
||||
@@ -64,10 +64,6 @@ class Message:
|
||||
def get_webxdc_status_updates(self, last_known_serial: int = 0) -> list:
|
||||
return json.loads(self._rpc.get_webxdc_status_updates(self.account.id, self.id, last_known_serial))
|
||||
|
||||
def get_info(self) -> str:
|
||||
"""Return message info."""
|
||||
return self._rpc.get_message_info(self.account.id, self.id)
|
||||
|
||||
def get_webxdc_info(self) -> dict:
|
||||
return self._rpc.get_webxdc_info(self.account.id, self.id)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
import random
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
import py
|
||||
import pytest
|
||||
|
||||
from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Message
|
||||
@@ -12,6 +11,14 @@ from ._utils import futuremethod
|
||||
from .rpc import Rpc
|
||||
|
||||
|
||||
def get_temp_credentials() -> dict:
|
||||
domain = os.getenv("CHATMAIL_DOMAIN")
|
||||
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
|
||||
password = f"{username}${username}"
|
||||
addr = f"{username}@{domain}"
|
||||
return {"email": addr, "password": password}
|
||||
|
||||
|
||||
class ACFactory:
|
||||
def __init__(self, deltachat: DeltaChat) -> None:
|
||||
self.deltachat = deltachat
|
||||
@@ -24,25 +31,26 @@ class ACFactory:
|
||||
def get_unconfigured_bot(self) -> Bot:
|
||||
return Bot(self.get_unconfigured_account())
|
||||
|
||||
def get_credentials(self) -> (str, str):
|
||||
domain = os.getenv("CHATMAIL_DOMAIN")
|
||||
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
|
||||
return f"{username}@{domain}", f"{username}${username}"
|
||||
def new_preconfigured_account(self) -> Account:
|
||||
"""Make a new account with configuration options set, but configuration not started."""
|
||||
credentials = get_temp_credentials()
|
||||
account = self.get_unconfigured_account()
|
||||
account.set_config("addr", credentials["email"])
|
||||
account.set_config("mail_pw", credentials["password"])
|
||||
assert not account.is_configured()
|
||||
return account
|
||||
|
||||
@futuremethod
|
||||
def new_configured_account(self):
|
||||
addr, password = self.get_credentials()
|
||||
account = self.get_unconfigured_account()
|
||||
params = {"addr": addr, "password": password}
|
||||
yield account.add_or_update_transport.future(params)
|
||||
|
||||
account = self.new_preconfigured_account()
|
||||
yield account.configure.future()
|
||||
assert account.is_configured()
|
||||
return account
|
||||
|
||||
def new_configured_bot(self) -> Bot:
|
||||
addr, password = self.get_credentials()
|
||||
credentials = get_temp_credentials()
|
||||
bot = self.get_unconfigured_bot()
|
||||
bot.configure(addr, password)
|
||||
bot.configure(credentials["email"], credentials["password"])
|
||||
return bot
|
||||
|
||||
@futuremethod
|
||||
@@ -79,7 +87,7 @@ class ACFactory:
|
||||
) -> Message:
|
||||
if not from_account:
|
||||
from_account = (self.get_online_accounts(1))[0]
|
||||
to_contact = from_account.create_contact(to_account)
|
||||
to_contact = from_account.create_contact(to_account.get_config("addr"))
|
||||
if group:
|
||||
to_chat = from_account.create_group(group)
|
||||
to_chat.add_contact(to_contact)
|
||||
@@ -116,50 +124,3 @@ def rpc(tmp_path) -> AsyncGenerator:
|
||||
@pytest.fixture
|
||||
def acfactory(rpc) -> AsyncGenerator:
|
||||
return ACFactory(DeltaChat(rpc))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data():
|
||||
"""Test data."""
|
||||
|
||||
class Data:
|
||||
def __init__(self) -> None:
|
||||
for path in reversed(py.path.local(__file__).parts()):
|
||||
datadir = path.join("test-data")
|
||||
if datadir.isdir():
|
||||
self.path = datadir
|
||||
return
|
||||
raise Exception("Data path cannot be found")
|
||||
|
||||
def get_path(self, bn):
|
||||
"""return path of file or None if it doesn't exist."""
|
||||
fn = os.path.join(self.path, *bn.split("/"))
|
||||
assert os.path.exists(fn)
|
||||
return fn
|
||||
|
||||
def read_path(self, bn, mode="r"):
|
||||
fn = self.get_path(bn)
|
||||
if fn is not None:
|
||||
with open(fn, mode) as f:
|
||||
return f.read()
|
||||
return None
|
||||
|
||||
return Data()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log():
|
||||
"""Log printer fixture."""
|
||||
|
||||
class Printer:
|
||||
def section(self, msg: str) -> None:
|
||||
print()
|
||||
print("=" * 10, msg, "=" * 10)
|
||||
|
||||
def step(self, msg: str) -> None:
|
||||
print("-" * 5, "step " + msg, "-" * 5)
|
||||
|
||||
def indent(self, msg: str) -> None:
|
||||
print(" " + msg)
|
||||
|
||||
return Printer()
|
||||
|
||||
@@ -13,11 +13,10 @@ def test_event_on_configuration(acfactory: ACFactory) -> None:
|
||||
Test if ACCOUNTS_ITEM_CHANGED event is emitted on configure
|
||||
"""
|
||||
|
||||
addr, password = acfactory.get_credentials()
|
||||
account = acfactory.get_unconfigured_account()
|
||||
account = acfactory.new_preconfigured_account()
|
||||
account.clear_all_events()
|
||||
assert not account.is_configured()
|
||||
future = account.add_or_update_transport.future({"addr": addr, "password": password})
|
||||
future = account.configure.future()
|
||||
while True:
|
||||
event = account.wait_for_event()
|
||||
if event.kind == EventType.ACCOUNTS_ITEM_CHANGED:
|
||||
|
||||
@@ -48,7 +48,8 @@ def test_delivery_status(acfactory: ACFactory) -> None:
|
||||
"""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
|
||||
alice.clear_all_events()
|
||||
@@ -118,7 +119,8 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
|
||||
"""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_text("hi")
|
||||
|
||||
@@ -148,7 +150,8 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
|
||||
def get_multi_account_test_setup(acfactory: ACFactory) -> [Account, Account, Account]:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_text("hi")
|
||||
|
||||
|
||||
@@ -175,11 +175,17 @@ def test_no_duplicate_messages(acfactory, path_to_webxdc):
|
||||
|
||||
threading.Thread(target=thread_run, daemon=True).start()
|
||||
|
||||
event = ac2.wait_for_event(EventType.WEBXDC_REALTIME_DATA)
|
||||
n = int(bytes(event.data).decode())
|
||||
while 1:
|
||||
event = ac2.wait_for_event()
|
||||
if event.kind == EventType.WEBXDC_REALTIME_DATA:
|
||||
n = int(bytes(event.data).decode())
|
||||
break
|
||||
|
||||
event = ac2.wait_for_event(EventType.WEBXDC_REALTIME_DATA)
|
||||
assert int(bytes(event.data).decode()) > n
|
||||
while 1:
|
||||
event = ac2.wait_for_event()
|
||||
if event.kind == EventType.WEBXDC_REALTIME_DATA:
|
||||
assert int(bytes(event.data).decode()) > n
|
||||
break
|
||||
|
||||
|
||||
def test_no_reordering(acfactory, path_to_webxdc):
|
||||
@@ -223,5 +229,8 @@ def test_advertisement_after_chatting(acfactory, path_to_webxdc):
|
||||
ac2_hello_msg_snapshot.chat.accept()
|
||||
|
||||
ac2_webxdc_msg.send_webxdc_realtime_advertisement()
|
||||
event = ac1.wait_for_event(EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED)
|
||||
assert event.msg_id == ac1_webxdc_msg.id
|
||||
while 1:
|
||||
event = ac1.wait_for_event()
|
||||
if event.kind == EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED:
|
||||
assert event.msg_id == ac1_webxdc_msg.id
|
||||
break
|
||||
|
||||
@@ -21,7 +21,6 @@ def test_autocrypt_setup_message_key_transfer(acfactory):
|
||||
alice2.set_config("addr", alice1.get_config("addr"))
|
||||
alice2.set_config("mail_pw", alice1.get_config("mail_pw"))
|
||||
alice2.configure()
|
||||
alice2.bring_online()
|
||||
|
||||
setup_code = alice1.initiate_autocrypt_key_transfer()
|
||||
msg = wait_for_autocrypt_setup_message(alice2)
|
||||
@@ -35,12 +34,10 @@ def test_autocrypt_setup_message_key_transfer(acfactory):
|
||||
|
||||
def test_ac_setup_message_twice(acfactory):
|
||||
alice1 = acfactory.get_online_account()
|
||||
|
||||
alice2 = acfactory.get_unconfigured_account()
|
||||
alice2.set_config("addr", alice1.get_config("addr"))
|
||||
alice2.set_config("mail_pw", alice1.get_config("mail_pw"))
|
||||
alice2.configure()
|
||||
alice2.bring_online()
|
||||
|
||||
# Send the first Autocrypt Setup Message and ignore it.
|
||||
_setup_code = alice1.initiate_autocrypt_key_transfer()
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
from imap_tools import AND
|
||||
|
||||
from deltachat_rpc_client import EventType
|
||||
from deltachat_rpc_client.const import MessageState
|
||||
|
||||
|
||||
def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1_clone = ac1.clone()
|
||||
ac1_clone.bring_online()
|
||||
|
||||
log.section("send out message without bcc to ourselves")
|
||||
ac1.set_config("bcc_self", "0")
|
||||
chat = ac1.create_chat(ac2)
|
||||
self_addr = ac1.get_config("addr")
|
||||
other_addr = ac2.get_config("addr")
|
||||
|
||||
msg_out = chat.send_text("message1")
|
||||
assert not msg_out.get_snapshot().is_forwarded
|
||||
|
||||
# wait for send out (no BCC)
|
||||
ev = ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT)
|
||||
assert ac1.get_config("bcc_self") == "0"
|
||||
|
||||
assert self_addr not in ev.msg
|
||||
assert other_addr in ev.msg
|
||||
|
||||
log.section("ac1: setting bcc_self=1")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
|
||||
log.section("send out message with bcc to ourselves")
|
||||
msg_out = chat.send_text("message2")
|
||||
|
||||
# wait for send out (BCC)
|
||||
ev = ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT)
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
|
||||
# Second client receives only second message, but not the first.
|
||||
ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED)
|
||||
assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == msg_out.get_snapshot().text
|
||||
|
||||
# now make sure we are sending message to ourselves too
|
||||
assert self_addr in ev.msg
|
||||
assert self_addr in ev.msg
|
||||
|
||||
# BCC-self messages are marked as seen by the sender device.
|
||||
while True:
|
||||
event = ac1.wait_for_event()
|
||||
if event.kind == EventType.INFO and event.msg.endswith("Marked messages 1 in folder INBOX as seen."):
|
||||
break
|
||||
|
||||
# Check that the message is marked as seen on IMAP.
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.connect()
|
||||
ac1_direct_imap.select_folder("Inbox")
|
||||
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True)))) == 1
|
||||
|
||||
|
||||
def test_multidevice_sync_seen(acfactory, log):
|
||||
"""Test that message marked as seen on one device is marked as seen on another."""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1_clone = ac1.clone()
|
||||
ac1_clone.bring_online()
|
||||
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1_clone.set_config("bcc_self", "1")
|
||||
|
||||
ac1_chat = ac1.create_chat(ac2)
|
||||
ac1_clone_chat = ac1_clone.create_chat(ac2)
|
||||
ac2_chat = ac2.create_chat(ac1)
|
||||
|
||||
log.section("Send a message from ac2 to ac1 and check that it's 'fresh'")
|
||||
ac2_chat.send_text("Hi")
|
||||
ac1_message = ac1.wait_for_incoming_msg()
|
||||
ac1_clone_message = ac1_clone.wait_for_incoming_msg()
|
||||
assert ac1_chat.get_fresh_message_count() == 1
|
||||
assert ac1_clone_chat.get_fresh_message_count() == 1
|
||||
assert ac1_message.get_snapshot().state == MessageState.IN_FRESH
|
||||
assert ac1_clone_message.get_snapshot().state == MessageState.IN_FRESH
|
||||
|
||||
log.section("ac1 marks message as seen on the first device")
|
||||
ac1.mark_seen_messages([ac1_message])
|
||||
assert ac1_message.get_snapshot().state == MessageState.IN_SEEN
|
||||
|
||||
log.section("ac1 clone detects that message is marked as seen")
|
||||
ev = ac1_clone.wait_for_event(EventType.MSGS_NOTICED)
|
||||
assert ev.chat_id == ac1_clone_chat.id
|
||||
|
||||
log.section("Send an ephemeral message from ac2 to ac1")
|
||||
ac2_chat.set_ephemeral_timer(60)
|
||||
ac1.wait_for_event(EventType.CHAT_EPHEMERAL_TIMER_MODIFIED)
|
||||
ac1.wait_for_incoming_msg()
|
||||
ac1_clone.wait_for_event(EventType.CHAT_EPHEMERAL_TIMER_MODIFIED)
|
||||
ac1_clone.wait_for_incoming_msg()
|
||||
|
||||
ac2_chat.send_text("Foobar")
|
||||
ac1_message = ac1.wait_for_incoming_msg()
|
||||
ac1_clone_message = ac1_clone.wait_for_incoming_msg()
|
||||
assert "Ephemeral timer: 60\n" in ac1_message.get_info()
|
||||
assert "Expires: " not in ac1_clone_message.get_info()
|
||||
assert "Ephemeral timer: 60\n" in ac1_message.get_info()
|
||||
assert "Expires: " not in ac1_clone_message.get_info()
|
||||
|
||||
ac1_message.mark_seen()
|
||||
assert "Expires: " in ac1_message.get_info()
|
||||
ev = ac1_clone.wait_for_event(EventType.MSGS_NOTICED)
|
||||
assert ev.chat_id == ac1_clone_chat.id
|
||||
assert ac1_clone_message.get_snapshot().state == MessageState.IN_SEEN
|
||||
# Test that the timer is started on the second device after synchronizing the seen status.
|
||||
assert "Expires: " in ac1_clone_message.get_info()
|
||||
@@ -76,11 +76,17 @@ def test_qr_securejoin(acfactory, protect):
|
||||
bob.secure_join(qr_code)
|
||||
|
||||
# Alice deletes "vg-request".
|
||||
alice.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
|
||||
while True:
|
||||
event = alice.wait_for_event()
|
||||
if event["kind"] == "ImapMessageDeleted":
|
||||
break
|
||||
alice.wait_for_securejoin_inviter_success()
|
||||
# Bob deletes "vg-auth-required", Alice deletes "vg-request-with-auth".
|
||||
for ac in [alice, bob]:
|
||||
ac.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
|
||||
while True:
|
||||
event = ac.wait_for_event()
|
||||
if event["kind"] == "ImapMessageDeleted":
|
||||
break
|
||||
bob.wait_for_securejoin_joiner_success()
|
||||
|
||||
# Test that Alice verified Bob's profile.
|
||||
@@ -89,7 +95,7 @@ def test_qr_securejoin(acfactory, protect):
|
||||
assert alice_contact_bob_snapshot.is_verified
|
||||
|
||||
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
|
||||
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
|
||||
assert snapshot.text == "Member Me ({}) added by {}.".format(bob.get_config("addr"), alice.get_config("addr"))
|
||||
assert snapshot.chat.get_basic_snapshot().is_protected == protect
|
||||
|
||||
# Test that Bob verified Alice's profile.
|
||||
@@ -117,7 +123,8 @@ def test_qr_securejoin_contact_request(acfactory) -> None:
|
||||
"""Alice invites Bob to a group when Bob's chat with Alice is in a contact request mode."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
@@ -154,8 +161,11 @@ def test_qr_readreceipt(acfactory) -> None:
|
||||
logging.info("Alice creates a verified group")
|
||||
group = alice.create_group("Group", protect=True)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
alice_contact_charlie = alice.create_contact(charlie, "Charlie")
|
||||
bob_addr = bob.get_config("addr")
|
||||
charlie_addr = charlie.get_config("addr")
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_contact_charlie = alice.create_contact(charlie_addr, "Charlie")
|
||||
|
||||
group.add_contact(alice_contact_bob)
|
||||
group.add_contact(alice_contact_charlie)
|
||||
@@ -182,7 +192,7 @@ def test_qr_readreceipt(acfactory) -> None:
|
||||
charlie_snapshot = charlie_message.get_snapshot()
|
||||
assert charlie_snapshot.text == "Hi from Bob!"
|
||||
|
||||
bob_contact_charlie = bob.create_contact(charlie, "Charlie")
|
||||
bob_contact_charlie = bob.create_contact(charlie_addr, "Charlie")
|
||||
assert not bob.get_chat_by_contact(bob_contact_charlie)
|
||||
|
||||
logging.info("Charlie reads Bob's message")
|
||||
@@ -453,12 +463,12 @@ def test_qr_new_group_unblocked(acfactory):
|
||||
assert ac2_msg.chat.get_basic_snapshot().is_contact_request
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="AEAP is disabled for now")
|
||||
def test_aeap_flow_verified(acfactory):
|
||||
"""Test that a new address is added to a contact when it changes its address."""
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
addr, password = acfactory.get_credentials()
|
||||
# ac1new is only used to get a new address.
|
||||
ac1new = acfactory.new_preconfigured_account()
|
||||
|
||||
logging.info("ac1: create verified-group QR, ac2 scans and joins")
|
||||
chat = ac1.create_group("hello", protect=True)
|
||||
@@ -478,8 +488,8 @@ def test_aeap_flow_verified(acfactory):
|
||||
assert msg_in_1.text == msg_out.text
|
||||
|
||||
logging.info("changing email account")
|
||||
ac1.set_config("addr", addr)
|
||||
ac1.set_config("mail_pw", password)
|
||||
ac1.set_config("addr", ac1new.get_config("addr"))
|
||||
ac1.set_config("mail_pw", ac1new.get_config("mail_pw"))
|
||||
ac1.stop_io()
|
||||
ac1.configure()
|
||||
ac1.start_io()
|
||||
@@ -492,9 +502,11 @@ def test_aeap_flow_verified(acfactory):
|
||||
msg_in_2_snapshot = msg_in_2.get_snapshot()
|
||||
assert msg_in_2_snapshot.text == msg_out.text
|
||||
assert msg_in_2_snapshot.chat.id == msg_in_1.chat.id
|
||||
assert msg_in_2.get_sender_contact().get_snapshot().address == addr
|
||||
assert msg_in_2.get_sender_contact().get_snapshot().address == ac1new.get_config("addr")
|
||||
assert len(msg_in_2_snapshot.chat.get_contacts()) == 2
|
||||
assert addr in [contact.get_snapshot().address for contact in msg_in_2_snapshot.chat.get_contacts()]
|
||||
assert ac1new.get_config("addr") in [
|
||||
contact.get_snapshot().address for contact in msg_in_2_snapshot.chat.get_contacts()
|
||||
]
|
||||
|
||||
|
||||
def test_gossip_verification(acfactory) -> None:
|
||||
@@ -510,9 +522,9 @@ def test_gossip_verification(acfactory) -> None:
|
||||
bob.secure_join(qr_code)
|
||||
bob.wait_for_securejoin_joiner_success()
|
||||
|
||||
bob_contact_alice = bob.create_contact(alice, "Alice")
|
||||
bob_contact_carol = bob.create_contact(carol, "Carol")
|
||||
carol_contact_alice = carol.create_contact(alice, "Alice")
|
||||
bob_contact_alice = bob.create_contact(alice.get_config("addr"), "Alice")
|
||||
bob_contact_carol = bob.create_contact(carol.get_config("addr"), "Carol")
|
||||
carol_contact_alice = carol.create_contact(alice.get_config("addr"), "Alice")
|
||||
|
||||
logging.info("Bob creates an Autocrypt group")
|
||||
bob_group_chat = bob.create_group("Autocrypt Group")
|
||||
@@ -563,7 +575,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
|
||||
|
||||
# ac1 waits for member added message and creates a QR code.
|
||||
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
|
||||
assert snapshot.text == "Member Me added by {}.".format(ac3.get_config("addr"))
|
||||
assert snapshot.text == "Member Me ({}) added by {}.".format(ac1.get_config("addr"), ac3.get_config("addr"))
|
||||
ac1_qr_code = snapshot.chat.get_qr_code()
|
||||
|
||||
# ac2 verifies ac1
|
||||
@@ -572,7 +584,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
|
||||
ac2.wait_for_securejoin_joiner_success()
|
||||
|
||||
# ac1 is verified for ac2.
|
||||
ac2_contact_ac1 = ac2.create_contact(ac1, "")
|
||||
ac2_contact_ac1 = ac2.create_contact(ac1.get_config("addr"), "")
|
||||
assert ac2_contact_ac1.get_snapshot().is_verified
|
||||
|
||||
# ac1 resetups the account.
|
||||
@@ -587,7 +599,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
|
||||
# header sent by old ac1.
|
||||
while True:
|
||||
# ac1 sends a message to ac2.
|
||||
ac1_contact_ac2 = ac1.create_contact(ac2, "")
|
||||
ac1_contact_ac2 = ac1.create_contact(ac2.get_config("addr"), "")
|
||||
ac1_chat_ac2 = ac1_contact_ac2.create_chat()
|
||||
ac1_chat_ac2.send_text("Hello!")
|
||||
|
||||
@@ -646,7 +658,7 @@ def test_withdraw_securejoin_qr(acfactory):
|
||||
alice.clear_all_events()
|
||||
|
||||
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
|
||||
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
|
||||
assert snapshot.text == "Member Me ({}) added by {}.".format(bob.get_config("addr"), alice.get_config("addr"))
|
||||
assert snapshot.chat.get_basic_snapshot().is_protected
|
||||
bob_chat.leave()
|
||||
|
||||
|
||||
@@ -61,102 +61,61 @@ def test_acfactory(acfactory) -> None:
|
||||
|
||||
|
||||
def test_configure_starttls(acfactory) -> None:
|
||||
addr, password = acfactory.get_credentials()
|
||||
account = acfactory.get_unconfigured_account()
|
||||
account.add_or_update_transport(
|
||||
{
|
||||
"addr": addr,
|
||||
"password": password,
|
||||
"imapSecurity": "starttls",
|
||||
"smtpSecurity": "starttls",
|
||||
},
|
||||
)
|
||||
account = acfactory.new_preconfigured_account()
|
||||
|
||||
# Use STARTTLS
|
||||
account.set_config("mail_security", "2")
|
||||
account.set_config("send_security", "2")
|
||||
account.configure()
|
||||
assert account.is_configured()
|
||||
|
||||
|
||||
def test_lowercase_address(acfactory) -> None:
|
||||
addr, password = acfactory.get_credentials()
|
||||
addr_upper = addr.upper()
|
||||
account = acfactory.get_unconfigured_account()
|
||||
account.add_or_update_transport(
|
||||
{
|
||||
"addr": addr_upper,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
assert account.is_configured()
|
||||
assert addr_upper != addr
|
||||
assert account.get_config("configured_addr") == addr
|
||||
assert account.list_transports()[0]["addr"] == addr
|
||||
|
||||
for param in [
|
||||
account.get_info()["used_account_settings"],
|
||||
account.get_info()["entered_account_settings"],
|
||||
]:
|
||||
assert addr in param
|
||||
assert addr_upper not in param
|
||||
|
||||
|
||||
def test_configure_ip(acfactory) -> None:
|
||||
addr, password = acfactory.get_credentials()
|
||||
account = acfactory.get_unconfigured_account()
|
||||
ip_address = socket.gethostbyname(addr.rsplit("@")[-1])
|
||||
account = acfactory.new_preconfigured_account()
|
||||
|
||||
domain = account.get_config("addr").rsplit("@")[-1]
|
||||
ip_address = socket.gethostbyname(domain)
|
||||
|
||||
# This should fail TLS check.
|
||||
account.set_config("mail_server", ip_address)
|
||||
with pytest.raises(JsonRpcError):
|
||||
account.add_or_update_transport(
|
||||
{
|
||||
"addr": addr,
|
||||
"password": password,
|
||||
# This should fail TLS check.
|
||||
"imapServer": ip_address,
|
||||
},
|
||||
)
|
||||
account.configure()
|
||||
|
||||
|
||||
def test_configure_alternative_port(acfactory) -> None:
|
||||
"""Test that configuration with alternative port 443 works."""
|
||||
addr, password = acfactory.get_credentials()
|
||||
account = acfactory.get_unconfigured_account()
|
||||
account.add_or_update_transport(
|
||||
{
|
||||
"addr": addr,
|
||||
"password": password,
|
||||
"imapPort": 443,
|
||||
"smtpPort": 443,
|
||||
},
|
||||
)
|
||||
assert account.is_configured()
|
||||
account = acfactory.new_preconfigured_account()
|
||||
|
||||
account.set_config("mail_port", "443")
|
||||
account.set_config("send_port", "443")
|
||||
|
||||
account.configure()
|
||||
|
||||
|
||||
def test_list_transports(acfactory) -> None:
|
||||
addr, password = acfactory.get_credentials()
|
||||
account = acfactory.get_unconfigured_account()
|
||||
account.add_or_update_transport(
|
||||
{
|
||||
"addr": addr,
|
||||
"password": password,
|
||||
"imapUser": addr,
|
||||
},
|
||||
)
|
||||
transports = account.list_transports()
|
||||
assert len(transports) == 1
|
||||
params = transports[0]
|
||||
assert params["addr"] == addr
|
||||
assert params["password"] == password
|
||||
assert params["imapUser"] == addr
|
||||
def test_configure_username(acfactory) -> None:
|
||||
account = acfactory.new_preconfigured_account()
|
||||
|
||||
addr = account.get_config("addr")
|
||||
account.set_config("mail_user", addr)
|
||||
account.configure()
|
||||
|
||||
assert account.get_config("configured_mail_user") == addr
|
||||
|
||||
|
||||
def test_account(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
event = bob.wait_for_incoming_msg_event()
|
||||
chat_id = event.chat_id
|
||||
msg_id = event.msg_id
|
||||
while True:
|
||||
event = bob.wait_for_event()
|
||||
if event.kind == EventType.INCOMING_MSG:
|
||||
chat_id = event.chat_id
|
||||
msg_id = event.msg_id
|
||||
break
|
||||
|
||||
message = bob.get_message_by_id(msg_id)
|
||||
snapshot = message.get_snapshot()
|
||||
@@ -215,7 +174,8 @@ def test_account(acfactory) -> None:
|
||||
def test_chat(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
@@ -281,7 +241,7 @@ def test_contact(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
|
||||
assert alice_contact_bob == alice.get_contact_by_id(alice_contact_bob.id)
|
||||
assert repr(alice_contact_bob)
|
||||
@@ -298,7 +258,8 @@ def test_contact(acfactory) -> None:
|
||||
def test_message(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
@@ -326,37 +287,13 @@ def test_message(acfactory) -> None:
|
||||
assert reactions == snapshot.reactions
|
||||
|
||||
|
||||
def test_selfavatar_sync(acfactory, data, log) -> None:
|
||||
alice = acfactory.get_online_account()
|
||||
|
||||
log.section("Alice adds a second device")
|
||||
alice2 = alice.clone()
|
||||
|
||||
log.section("Second device goes online")
|
||||
alice2.start_io()
|
||||
|
||||
log.section("First device changes avatar")
|
||||
image = data.get_path("image/avatar1000x1000.jpg")
|
||||
alice.set_config("selfavatar", image)
|
||||
avatar_config = alice.get_config("selfavatar")
|
||||
avatar_hash = os.path.basename(avatar_config)
|
||||
print("Info: avatar hash is ", avatar_hash)
|
||||
|
||||
log.section("First device receives avatar change")
|
||||
alice2.wait_for_event(EventType.SELFAVATAR_CHANGED)
|
||||
avatar_config2 = alice2.get_config("selfavatar")
|
||||
avatar_hash2 = os.path.basename(avatar_config2)
|
||||
print("Info: avatar hash on second device is ", avatar_hash2)
|
||||
assert avatar_hash == avatar_hash2
|
||||
assert avatar_config != avatar_config2
|
||||
|
||||
|
||||
def test_reaction_seen_on_another_dev(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
alice2 = alice.clone()
|
||||
alice2.start_io()
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
@@ -368,12 +305,20 @@ def test_reaction_seen_on_another_dev(acfactory) -> None:
|
||||
snapshot.chat.accept()
|
||||
message.send_reaction("😎")
|
||||
for a in [alice, alice2]:
|
||||
a.wait_for_event(EventType.INCOMING_REACTION)
|
||||
while True:
|
||||
event = a.wait_for_event()
|
||||
if event.kind == EventType.INCOMING_REACTION:
|
||||
break
|
||||
|
||||
alice2.clear_all_events()
|
||||
alice_chat_bob.mark_noticed()
|
||||
chat_id = alice2.wait_for_event(EventType.MSGS_NOTICED).chat_id
|
||||
alice2_chat_bob = alice2.create_chat(bob)
|
||||
while True:
|
||||
event = alice2.wait_for_event()
|
||||
if event.kind == EventType.MSGS_NOTICED:
|
||||
chat_id = event.chat_id
|
||||
break
|
||||
alice2_contact_bob = alice2.get_contact_by_addr(bob_addr)
|
||||
alice2_chat_bob = alice2_contact_bob.create_chat()
|
||||
assert chat_id == alice2_chat_bob.id
|
||||
|
||||
|
||||
@@ -381,19 +326,24 @@ def test_is_bot(acfactory) -> None:
|
||||
"""Test that we can recognize messages submitted by bots."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
|
||||
# Alice becomes a bot.
|
||||
alice.set_config("bot", "1")
|
||||
alice_chat_bob.send_text("Hello!")
|
||||
|
||||
event = bob.wait_for_incoming_msg_event()
|
||||
message = bob.get_message_by_id(event.msg_id)
|
||||
snapshot = message.get_snapshot()
|
||||
assert snapshot.chat_id == event.chat_id
|
||||
assert snapshot.text == "Hello!"
|
||||
assert snapshot.is_bot
|
||||
while True:
|
||||
event = bob.wait_for_event()
|
||||
if event.kind == EventType.INCOMING_MSG:
|
||||
msg_id = event.msg_id
|
||||
message = bob.get_message_by_id(msg_id)
|
||||
snapshot = message.get_snapshot()
|
||||
assert snapshot.chat_id == event.chat_id
|
||||
assert snapshot.text == "Hello!"
|
||||
assert snapshot.is_bot
|
||||
break
|
||||
|
||||
|
||||
def test_bot(acfactory) -> None:
|
||||
@@ -440,11 +390,9 @@ def test_wait_next_messages(acfactory) -> None:
|
||||
alice = acfactory.new_configured_account()
|
||||
|
||||
# Create a bot account so it does not receive device messages in the beginning.
|
||||
addr, password = acfactory.get_credentials()
|
||||
bot = acfactory.get_unconfigured_account()
|
||||
bot = acfactory.new_preconfigured_account()
|
||||
bot.set_config("bot", "1")
|
||||
bot.add_or_update_transport({"addr": addr, "password": password})
|
||||
assert bot.is_configured()
|
||||
bot.configure()
|
||||
|
||||
# There are no old messages and the call returns immediately.
|
||||
assert not bot.wait_next_messages()
|
||||
@@ -453,7 +401,8 @@ def test_wait_next_messages(acfactory) -> None:
|
||||
# Bot starts waiting for messages.
|
||||
next_messages_task = executor.submit(bot.wait_next_messages)
|
||||
|
||||
alice_contact_bot = alice.create_contact(bot, "Bot")
|
||||
bot_addr = bot.get_config("addr")
|
||||
alice_contact_bot = alice.create_contact(bot_addr, "Bot")
|
||||
alice_chat_bot = alice_contact_bot.create_chat()
|
||||
alice_chat_bot.send_text("Hello!")
|
||||
|
||||
@@ -477,7 +426,9 @@ def test_import_export_backup(acfactory, tmp_path) -> None:
|
||||
def test_import_export_keys(acfactory, tmp_path) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_chat_bob = alice.create_chat(bob)
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_text("Hello Bob!")
|
||||
|
||||
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
|
||||
@@ -527,7 +478,9 @@ def test_provider_info(rpc) -> None:
|
||||
def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
|
||||
# Bob creates chat manually so chat with Alice is accepted.
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
@@ -551,7 +504,10 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
|
||||
|
||||
# Alice reads Bob's message.
|
||||
message.mark_seen()
|
||||
bob.wait_for_event(EventType.MSG_READ)
|
||||
while True:
|
||||
event = bob.wait_for_event()
|
||||
if event.kind == EventType.MSG_READ:
|
||||
break
|
||||
|
||||
# Bob sends a message to Alice, it should also be encrypted.
|
||||
bob_chat_alice.send_text("Hi Alice!")
|
||||
@@ -623,13 +579,9 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
|
||||
messages they refer to and thus dropped.
|
||||
"""
|
||||
(ac1,) = acfactory.get_online_accounts(1)
|
||||
|
||||
addr, password = acfactory.get_credentials()
|
||||
ac2 = acfactory.get_unconfigured_account()
|
||||
ac2.add_or_update_transport({"addr": addr, "password": password})
|
||||
ac2 = acfactory.new_preconfigured_account()
|
||||
ac2.configure()
|
||||
ac2.set_config("mvbox_move", "1")
|
||||
assert ac2.is_configured()
|
||||
|
||||
ac2.bring_online()
|
||||
chat1 = acfactory.get_accepted_chat(ac1, ac2)
|
||||
ac2.stop_io()
|
||||
@@ -673,7 +625,9 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
|
||||
chat.send_text("Hello Alice!")
|
||||
assert alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot().text == "Hello Alice!"
|
||||
|
||||
contact = alice.create_contact(account)
|
||||
contact_addr = account.get_config("addr")
|
||||
contact = alice.create_contact(contact_addr, "")
|
||||
|
||||
alice_group.add_contact(contact)
|
||||
|
||||
if n_accounts == 2:
|
||||
@@ -723,7 +677,10 @@ def test_markseen_contact_request(acfactory):
|
||||
assert message2.get_snapshot().state == MessageState.IN_FRESH
|
||||
|
||||
message.mark_seen()
|
||||
bob2.wait_for_event(EventType.MSGS_NOTICED)
|
||||
while True:
|
||||
event = bob2.wait_for_event()
|
||||
if event.kind == EventType.MSGS_NOTICED:
|
||||
break
|
||||
assert message2.get_snapshot().state == MessageState.IN_SEEN
|
||||
|
||||
|
||||
@@ -736,11 +693,12 @@ def test_get_http_response(acfactory):
|
||||
|
||||
def test_configured_imap_certificate_checks(acfactory):
|
||||
alice = acfactory.new_configured_account()
|
||||
configured_certificate_checks = alice.get_config("configured_imap_certificate_checks")
|
||||
|
||||
# Certificate checks should be configured (not None)
|
||||
assert "cert_automatic" in alice.get_info().used_account_settings
|
||||
assert configured_certificate_checks
|
||||
|
||||
# "cert_old_automatic" is the value old Delta Chat core versions used
|
||||
# 0 is the value old Delta Chat core versions used
|
||||
# to mean user entered "imap_certificate_checks=0" (Automatic)
|
||||
# and configuration failed to use strict TLS checks
|
||||
# so it switched strict TLS checks off.
|
||||
@@ -751,7 +709,7 @@ def test_configured_imap_certificate_checks(acfactory):
|
||||
#
|
||||
# Core 1.142.4, 1.142.5 and 1.142.6 saved this value due to bug.
|
||||
# This test is a regression test to prevent this happening again.
|
||||
assert "cert_old_automatic" not in alice.get_info().used_account_settings
|
||||
assert configured_certificate_checks != "0"
|
||||
|
||||
|
||||
def test_no_old_msg_is_fresh(acfactory):
|
||||
@@ -770,8 +728,6 @@ def test_no_old_msg_is_fresh(acfactory):
|
||||
assert ac1.create_chat(ac2).get_fresh_message_count() == 1
|
||||
assert len(list(ac1.get_fresh_messages())) == 1
|
||||
|
||||
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
|
||||
|
||||
logging.info("Send a message from ac1_clone to ac2 and check that ac1 marks the first message as 'noticed'")
|
||||
ac1_clone_chat.send_text("Hi back")
|
||||
ev = ac1.wait_for_msgs_noticed_event()
|
||||
@@ -779,71 +735,3 @@ def test_no_old_msg_is_fresh(acfactory):
|
||||
assert ev.chat_id == first_msg.get_snapshot().chat_id
|
||||
assert ac1.create_chat(ac2).get_fresh_message_count() == 0
|
||||
assert len(list(ac1.get_fresh_messages())) == 0
|
||||
|
||||
|
||||
def test_rename_synchronization(acfactory):
|
||||
"""Test synchronization of contact renaming."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
alice2 = alice.clone()
|
||||
alice2.bring_online()
|
||||
|
||||
bob.set_config("displayname", "Bob")
|
||||
bob.create_chat(alice).send_text("Hello!")
|
||||
alice_msg = alice.wait_for_incoming_msg().get_snapshot()
|
||||
alice2_msg = alice2.wait_for_incoming_msg().get_snapshot()
|
||||
|
||||
assert alice2_msg.sender.get_snapshot().display_name == "Bob"
|
||||
alice_msg.sender.set_name("Bobby")
|
||||
alice2.wait_for_event(EventType.CONTACTS_CHANGED)
|
||||
assert alice2_msg.sender.get_snapshot().display_name == "Bobby"
|
||||
|
||||
|
||||
def test_rename_group(acfactory):
|
||||
"""Test renaming the group."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_group = alice.create_group("Test group")
|
||||
alice_contact_bob = alice.create_contact(bob)
|
||||
alice_group.add_contact(alice_contact_bob)
|
||||
alice_group.send_text("Hello!")
|
||||
|
||||
bob_msg = bob.wait_for_incoming_msg()
|
||||
bob_chat = bob_msg.get_snapshot().chat
|
||||
assert bob_chat.get_basic_snapshot().name == "Test group"
|
||||
|
||||
for name in ["Baz", "Foo bar", "Xyzzy"]:
|
||||
alice_group.set_name(name)
|
||||
bob.wait_for_incoming_msg_event()
|
||||
assert bob_chat.get_basic_snapshot().name == name
|
||||
|
||||
|
||||
def test_get_all_accounts_deadlock(rpc):
|
||||
"""Regression test for get_all_accounts deadlock."""
|
||||
for _ in range(100):
|
||||
all_accounts = rpc.get_all_accounts.future()
|
||||
rpc.add_account()
|
||||
all_accounts()
|
||||
|
||||
|
||||
def test_delete_deltachat_folder(acfactory, direct_imap):
|
||||
"""Test that DeltaChat folder is recreated if user deletes it manually."""
|
||||
ac1 = acfactory.new_configured_account()
|
||||
ac1.set_config("mvbox_move", "1")
|
||||
ac1.bring_online()
|
||||
|
||||
ac1_direct_imap = direct_imap(ac1)
|
||||
ac1_direct_imap.conn.folder.delete("DeltaChat")
|
||||
assert "DeltaChat" not in ac1_direct_imap.list_folders()
|
||||
|
||||
# Wait until new folder is created and UIDVALIDITY is updated.
|
||||
while True:
|
||||
event = ac1.wait_for_event()
|
||||
if event.kind == EventType.INFO and "uid/validity change folder DeltaChat" in event.msg:
|
||||
break
|
||||
|
||||
ac2 = acfactory.get_online_account()
|
||||
ac2.create_chat(ac1).send_text("hello")
|
||||
msg = ac1.wait_for_incoming_msg().get_snapshot()
|
||||
assert msg.text == "hello"
|
||||
|
||||
assert "DeltaChat" in ac1_direct_imap.list_folders()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
def test_vcard(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_contact_charlie = alice.create_contact("charlie@example.org", "Charlie")
|
||||
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
from deltachat_rpc_client import EventType
|
||||
|
||||
|
||||
def test_webxdc(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
alice_chat_bob.send_message(text="Let's play chess!", file="../test-data/webxdc/chess.xdc")
|
||||
|
||||
event = bob.wait_for_incoming_msg_event()
|
||||
bob_chat_alice = bob.get_chat_by_id(event.chat_id)
|
||||
message = bob.get_message_by_id(event.msg_id)
|
||||
while True:
|
||||
event = bob.wait_for_event()
|
||||
if event.kind == EventType.INCOMING_MSG:
|
||||
bob_chat_alice = bob.get_chat_by_id(event.chat_id)
|
||||
message = bob.get_message_by_id(event.msg_id)
|
||||
break
|
||||
|
||||
webxdc_info = message.get_webxdc_info()
|
||||
assert webxdc_info == {
|
||||
@@ -44,7 +51,8 @@ def test_webxdc(acfactory) -> None:
|
||||
def test_webxdc_insert_lots_of_updates(acfactory) -> None:
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
bob_addr = bob.get_config("addr")
|
||||
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
|
||||
alice_chat_bob = alice_contact_bob.create_chat()
|
||||
message = alice_chat_bob.send_message(text="Let's play chess!", file="../test-data/webxdc/chess.xdc")
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ setenv =
|
||||
RUST_MIN_STACK=8388608
|
||||
passenv =
|
||||
CHATMAIL_DOMAIN
|
||||
dependency_groups =
|
||||
dev
|
||||
deps =
|
||||
pytest
|
||||
pytest-timeout
|
||||
pytest-xdist
|
||||
imap-tools
|
||||
|
||||
[testenv:lint]
|
||||
skipsdist = True
|
||||
@@ -21,7 +24,7 @@ skip_install = True
|
||||
deps =
|
||||
ruff
|
||||
commands =
|
||||
ruff format --diff src/ examples/ tests/
|
||||
ruff format --quiet --diff src/ examples/ tests/
|
||||
ruff check src/ examples/ tests/
|
||||
|
||||
[pytest]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "1.159.3"
|
||||
version = "1.157.1"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "1.159.3"
|
||||
"version": "1.157.1"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ async fn main() {
|
||||
// thread, and it is impossible to cancel that read. This can make shutdown of the runtime hang
|
||||
// until the user presses enter."
|
||||
if let Err(error) = &r {
|
||||
log::error!("Error: {error:#}.")
|
||||
log::error!("Fatal error: {error:#}.")
|
||||
}
|
||||
std::process::exit(if r.is_ok() { 0 } else { 1 });
|
||||
}
|
||||
|
||||
12
deny.toml
12
deny.toml
@@ -24,18 +24,19 @@ ignore = [
|
||||
# Please keep this list alphabetically sorted.
|
||||
skip = [
|
||||
{ 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 = "core-foundation", version = "0.9.4" },
|
||||
{ name = "event-listener", version = "2.5.3" },
|
||||
{ name = "generator", version = "0.7.5" },
|
||||
{ name = "getrandom", version = "0.2.12" },
|
||||
{ name = "heck", version = "0.4.1" },
|
||||
{ name = "http", version = "0.2.12" },
|
||||
{ name = "linux-raw-sys", version = "0.4.14" },
|
||||
{ name = "loom", version = "0.5.6" },
|
||||
{ name = "netlink-packet-route", version = "0.17.1" },
|
||||
{ name = "nix", version = "0.26.4" },
|
||||
{ name = "nix", version = "0.27.1" },
|
||||
{ name = "quick-error", version = "<2.0" },
|
||||
{ name = "rand_chacha", version = "0.3.1" },
|
||||
{ name = "rand_core", version = "0.6.4" },
|
||||
{ name = "rand", version = "0.8.5" },
|
||||
@@ -43,13 +44,14 @@ skip = [
|
||||
{ name = "regex-automata", version = "0.1.10" },
|
||||
{ name = "regex-syntax", version = "0.6.29" },
|
||||
{ name = "rtnetlink", version = "0.13.1" },
|
||||
{ name = "rustix", version = "0.38.44" },
|
||||
{ name = "security-framework", version = "2.11.1" },
|
||||
{ name = "strum_macros", version = "0.26.2" },
|
||||
{ name = "strum", version = "0.26.2" },
|
||||
{ name = "sync_wrapper", version = "0.1.2" },
|
||||
{ name = "syn", version = "1.0.109" },
|
||||
{ name = "thiserror-impl", version = "1.0.69" },
|
||||
{ name = "thiserror", version = "1.0.69" },
|
||||
{ name = "tokio-tungstenite", version = "0.21.0" },
|
||||
{ name = "tungstenite", version = "0.21.0" },
|
||||
{ name = "unicode-width", version = "0.1.11" },
|
||||
{ name = "wasi", version = "0.11.0+wasi-snapshot-preview1" },
|
||||
{ name = "windows" },
|
||||
{ name = "windows_aarch64_gnullvm" },
|
||||
|
||||
@@ -584,9 +584,6 @@
|
||||
cargo-nextest
|
||||
perl # needed to build vendored OpenSSL
|
||||
git-cliff
|
||||
(python3.withPackages (pypkgs: with pypkgs; [
|
||||
tox
|
||||
]))
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ def test_echo_quit_plugin(acfactory, lp):
|
||||
(ac1,) = acfactory.get_online_accounts(1)
|
||||
|
||||
lp.sec("sending a message to the bot")
|
||||
bot_chat = ac1.qr_setup_contact(botproc.qr)
|
||||
ac1._evtracker.wait_securejoin_joiner_progress(1000)
|
||||
bot_contact = ac1.create_contact(botproc.addr)
|
||||
bot_chat = bot_contact.create_chat()
|
||||
bot_chat.send_text("hello")
|
||||
|
||||
lp.sec("waiting for the reply message from the bot to arrive")
|
||||
@@ -48,9 +48,7 @@ def test_group_tracking_plugin(acfactory, lp):
|
||||
ac2.add_account_plugin(FFIEventLogger(ac2))
|
||||
|
||||
lp.sec("creating bot test group with bot")
|
||||
bot_chat = ac1.qr_setup_contact(botproc.qr)
|
||||
ac1._evtracker.wait_securejoin_joiner_progress(1000)
|
||||
bot_contact = bot_chat.get_contacts()[0]
|
||||
bot_contact = ac1.create_contact(botproc.addr)
|
||||
ch = ac1.create_group_chat("bot test group")
|
||||
ch.add_contact(bot_contact)
|
||||
ch.send_text("hello")
|
||||
@@ -62,7 +60,7 @@ def test_group_tracking_plugin(acfactory, lp):
|
||||
)
|
||||
|
||||
lp.sec("adding third member {}".format(ac2.get_config("addr")))
|
||||
contact3 = ac1.create_contact(ac2)
|
||||
contact3 = ac1.create_contact(ac2.get_config("addr"))
|
||||
ch.add_contact(contact3)
|
||||
|
||||
reply = ac1._evtracker.wait_next_incoming_message()
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "1.159.3"
|
||||
version = "1.157.1"
|
||||
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
@@ -55,8 +55,6 @@ def run_cmdline(argv=None, account_plugins=None):
|
||||
args = parser.parse_args(argv[1:])
|
||||
|
||||
ac = Account(args.db)
|
||||
qr = ac.get_setup_contact_qr()
|
||||
print(qr)
|
||||
|
||||
ac.run_account(addr=args.email, password=args.password, account_plugins=account_plugins, show_ffi=args.show_ffi)
|
||||
|
||||
|
||||
@@ -280,12 +280,6 @@ class Account:
|
||||
:param name: (optional) display name for this contact
|
||||
:returns: :class:`deltachat.contact.Contact` instance.
|
||||
"""
|
||||
if isinstance(obj, Account):
|
||||
if not obj.is_configured():
|
||||
raise ValueError("Can only add configured accounts as contacts")
|
||||
assert name is None
|
||||
vcard = obj.get_self_contact().make_vcard()
|
||||
return self.import_vcard(vcard)[0]
|
||||
(name, addr) = self.get_contact_addr_and_name(obj, name)
|
||||
name_c = as_dc_charpointer(name)
|
||||
addr_c = as_dc_charpointer(addr)
|
||||
@@ -374,11 +368,6 @@ class Account:
|
||||
dc_array = ffi.gc(lib.dc_get_contacts(self._dc_context, flags, query_c), lib.dc_array_unref)
|
||||
return list(iter_array(dc_array, lambda x: Contact(self, x)))
|
||||
|
||||
def import_vcard(self, vcard):
|
||||
"""Import a vCard and return an array of contacts."""
|
||||
dc_array = ffi.gc(lib.dc_import_vcard(self._dc_context, as_dc_charpointer(vcard)), lib.dc_array_unref)
|
||||
return list(iter_array(dc_array, lambda x: Contact(self, x)))
|
||||
|
||||
def get_fresh_messages(self) -> Generator[Message, None, None]:
|
||||
"""yield all fresh messages from all chats."""
|
||||
dc_array = ffi.gc(lib.dc_get_fresh_msgs(self._dc_context), lib.dc_array_unref)
|
||||
|
||||
@@ -90,14 +90,6 @@ class Contact:
|
||||
dc_res = lib.dc_contact_get_profile_image(self._dc_contact)
|
||||
return from_optional_dc_charpointer(dc_res)
|
||||
|
||||
def make_vcard(self) -> str:
|
||||
"""Make a contact vCard.
|
||||
|
||||
:returns: vCard
|
||||
"""
|
||||
dc_context = self.account._dc_context
|
||||
return from_dc_charpointer(lib.dc_make_vcard(dc_context, self.id))
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
"""Get contact status.
|
||||
|
||||
@@ -482,8 +482,12 @@ class ACFactory:
|
||||
addr = f"{acname}@offline.org"
|
||||
ac.update_config(
|
||||
{
|
||||
"configured_addr": addr,
|
||||
"addr": addr,
|
||||
"displayname": acname,
|
||||
"mail_pw": "123",
|
||||
"configured_addr": addr,
|
||||
"configured_mail_pw": "123",
|
||||
"configured": "1",
|
||||
},
|
||||
)
|
||||
self._preconfigure_key(ac)
|
||||
@@ -645,9 +649,6 @@ class BotProcess:
|
||||
|
||||
def __init__(self, popen, addr) -> None:
|
||||
self.popen = popen
|
||||
|
||||
# The first thing the bot prints to stdout is an invite link.
|
||||
self.qr = self.popen.stdout.readline()
|
||||
self.addr = addr
|
||||
|
||||
# we read stdout as quickly as we can in a thread and make
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import deltachat as dc
|
||||
|
||||
|
||||
@@ -16,6 +17,8 @@ class TestGroupStressTests:
|
||||
lp.sec("ac1: send message to new group chat")
|
||||
msg1 = chat.send_text("hello")
|
||||
assert msg1.is_encrypted()
|
||||
gossiped_timestamp = chat.get_summary()["gossiped_timestamp"]
|
||||
assert gossiped_timestamp > 0
|
||||
|
||||
assert chat.num_contacts() == 3 + 1
|
||||
|
||||
@@ -44,13 +47,19 @@ class TestGroupStressTests:
|
||||
assert to_remove.addr in sysmsg.text
|
||||
assert sysmsg.chat.num_contacts() == 3
|
||||
|
||||
# Receiving message about removed contact does not reset gossip
|
||||
assert chat.get_summary()["gossiped_timestamp"] == gossiped_timestamp
|
||||
|
||||
lp.sec("ac1: sending another message to the chat")
|
||||
chat.send_text("hello2")
|
||||
msg = ac2._evtracker.wait_next_incoming_message()
|
||||
assert msg.text == "hello2"
|
||||
assert chat.get_summary()["gossiped_timestamp"] == gossiped_timestamp
|
||||
|
||||
lp.sec("ac1: adding fifth member to the chat")
|
||||
chat.add_contact(ac5)
|
||||
# Adding contact to chat resets gossiped_timestamp
|
||||
assert chat.get_summary()["gossiped_timestamp"] >= gossiped_timestamp
|
||||
|
||||
lp.sec("ac2: receiving system message about contact addition")
|
||||
sysmsg = ac2._evtracker.wait_next_incoming_message()
|
||||
@@ -187,6 +196,118 @@ def test_qr_verified_group_and_chatting(acfactory, lp):
|
||||
assert msg.is_encrypted()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mvbox_move", [False, True])
|
||||
def test_fetch_existing(acfactory, lp, mvbox_move):
|
||||
"""Delta Chat reads the recipients from old emails sent by the user and adds them as contacts.
|
||||
This way, we can already offer them some email addresses they can write to.
|
||||
|
||||
Also, the newest existing emails from each folder are fetched during onboarding.
|
||||
|
||||
Additionally tests that bcc_self messages moved to the mvbox/sentbox are marked as read."""
|
||||
|
||||
def assert_folders_configured(ac):
|
||||
"""There was a bug that scan_folders() set the configured folders to None under some circumstances.
|
||||
So, check that they are still configured:"""
|
||||
assert ac.get_config("configured_sentbox_folder") == "Sent"
|
||||
if mvbox_move:
|
||||
assert ac.get_config("configured_mvbox_folder")
|
||||
|
||||
ac1 = acfactory.new_online_configuring_account(mvbox_move=mvbox_move)
|
||||
ac2 = acfactory.new_online_configuring_account()
|
||||
acfactory.wait_configured(ac1)
|
||||
ac1.direct_imap.create_folder("Sent")
|
||||
ac1.set_config("sentbox_watch", "1")
|
||||
|
||||
# We need to reconfigure to find the new "Sent" folder.
|
||||
# `scan_folders()`, which runs automatically shortly after `start_io()` is invoked,
|
||||
# would also find the "Sent" folder, but it would be too late:
|
||||
# The sentbox thread, started by `start_io()`, would have seen that there is no
|
||||
# ConfiguredSentboxFolder and do nothing.
|
||||
acfactory._acsetup.start_configure(ac1)
|
||||
acfactory.bring_accounts_online()
|
||||
assert_folders_configured(ac1)
|
||||
|
||||
lp.sec("send out message with bcc to ourselves")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
chat.send_text("message text")
|
||||
|
||||
lp.sec("wait until the bcc_self message arrives in correct folder and is marked seen")
|
||||
if mvbox_move:
|
||||
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder DeltaChat as seen.")
|
||||
else:
|
||||
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
assert_folders_configured(ac1)
|
||||
|
||||
lp.sec("create a cloned ac1 and fetch contact history during configure")
|
||||
ac1_clone = acfactory.new_online_configuring_account(cloned_from=ac1)
|
||||
ac1_clone.set_config("fetch_existing_msgs", "1")
|
||||
acfactory.wait_configured(ac1_clone)
|
||||
ac1_clone.start_io()
|
||||
assert_folders_configured(ac1_clone)
|
||||
|
||||
lp.sec("check that ac2 contact was fetched during configure")
|
||||
ac1_clone._evtracker.get_matching("DC_EVENT_CONTACTS_CHANGED")
|
||||
ac2_addr = ac2.get_config("addr")
|
||||
assert any(c.addr == ac2_addr for c in ac1_clone.get_contacts())
|
||||
assert_folders_configured(ac1_clone)
|
||||
|
||||
lp.sec("check that messages changed events arrive for the correct message")
|
||||
msg = ac1_clone._evtracker.wait_next_messages_changed()
|
||||
assert msg.text == "message text"
|
||||
assert_folders_configured(ac1)
|
||||
assert_folders_configured(ac1_clone)
|
||||
|
||||
|
||||
def test_fetch_existing_msgs_group_and_single(acfactory, lp):
|
||||
"""There was a bug concerning fetch-existing-msgs:
|
||||
|
||||
A sent a message to you, adding you to a group. This created a contact request.
|
||||
You wrote a message to A, creating a chat.
|
||||
...but the group stayed blocked.
|
||||
So, after fetch-existing-msgs you have one contact request and one chat with the same person.
|
||||
|
||||
See https://github.com/deltachat/deltachat-core-rust/issues/2097"""
|
||||
ac1 = acfactory.new_online_configuring_account()
|
||||
ac2 = acfactory.new_online_configuring_account()
|
||||
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
lp.sec("receive a message")
|
||||
ac2.create_group_chat("group name", contacts=[ac1]).send_text("incoming, unencrypted group message")
|
||||
ac1._evtracker.wait_next_incoming_message()
|
||||
|
||||
lp.sec("send out message with bcc to ourselves")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1_ac2_chat = ac1.create_chat(ac2)
|
||||
ac1_ac2_chat.send_text("outgoing, encrypted direct message, creating a chat")
|
||||
|
||||
# wait until the bcc_self message arrives
|
||||
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
|
||||
lp.sec("Clone online account and let it fetch the existing messages")
|
||||
ac1_clone = acfactory.new_online_configuring_account(cloned_from=ac1)
|
||||
ac1_clone.set_config("fetch_existing_msgs", "1")
|
||||
acfactory.wait_configured(ac1_clone)
|
||||
|
||||
ac1_clone.start_io()
|
||||
ac1_clone._evtracker.wait_idle_inbox_ready()
|
||||
|
||||
chats = ac1_clone.get_chats()
|
||||
assert len(chats) == 4 # two newly created chats + self-chat + device-chat
|
||||
group_chat = [c for c in chats if c.get_name() == "group name"][0]
|
||||
assert group_chat.is_group()
|
||||
(private_chat,) = [c for c in chats if c.get_name() == ac1_ac2_chat.get_name()]
|
||||
assert not private_chat.is_group()
|
||||
|
||||
group_messages = group_chat.get_messages()
|
||||
assert len(group_messages) == 1
|
||||
assert group_messages[0].text == "incoming, unencrypted group message"
|
||||
private_messages = private_chat.get_messages()
|
||||
# We can't decrypt the message in this chat, so the chat is empty:
|
||||
assert len(private_messages) == 0
|
||||
|
||||
|
||||
def test_undecipherable_group(acfactory, lp):
|
||||
"""Test how group messages that cannot be decrypted are
|
||||
handled.
|
||||
@@ -323,6 +444,63 @@ def test_ephemeral_timer(acfactory, lp):
|
||||
assert chat1.get_ephemeral_timer() == 0
|
||||
|
||||
|
||||
def test_multidevice_sync_seen(acfactory, lp):
|
||||
"""Test that message marked as seen on one device is marked as seen on another."""
|
||||
ac1 = acfactory.new_online_configuring_account()
|
||||
ac2 = acfactory.new_online_configuring_account()
|
||||
ac1_clone = acfactory.new_online_configuring_account(cloned_from=ac1)
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
ac1.set_config("bcc_self", "1")
|
||||
ac1_clone.set_config("bcc_self", "1")
|
||||
|
||||
ac1_chat = ac1.create_chat(ac2)
|
||||
ac1_clone_chat = ac1_clone.create_chat(ac2)
|
||||
ac2_chat = ac2.create_chat(ac1)
|
||||
|
||||
lp.sec("Send a message from ac2 to ac1 and check that it's 'fresh'")
|
||||
ac2_chat.send_text("Hi")
|
||||
ac1_message = ac1._evtracker.wait_next_incoming_message()
|
||||
ac1_clone_message = ac1_clone._evtracker.wait_next_incoming_message()
|
||||
assert ac1_chat.count_fresh_messages() == 1
|
||||
assert ac1_clone_chat.count_fresh_messages() == 1
|
||||
assert ac1_message.is_in_fresh
|
||||
assert ac1_clone_message.is_in_fresh
|
||||
|
||||
lp.sec("ac1 marks message as seen on the first device")
|
||||
ac1.mark_seen_messages([ac1_message])
|
||||
assert ac1_message.is_in_seen
|
||||
|
||||
lp.sec("ac1 clone detects that message is marked as seen")
|
||||
ev = ac1_clone._evtracker.get_matching("DC_EVENT_MSGS_NOTICED")
|
||||
assert ev.data1 == ac1_clone_chat.id
|
||||
assert ac1_clone_message.is_in_seen
|
||||
|
||||
lp.sec("Send an ephemeral message from ac2 to ac1")
|
||||
ac2_chat.set_ephemeral_timer(60)
|
||||
ac1._evtracker.get_matching("DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED")
|
||||
ac1._evtracker.wait_next_incoming_message()
|
||||
ac1_clone._evtracker.get_matching("DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED")
|
||||
ac1_clone._evtracker.wait_next_incoming_message()
|
||||
|
||||
ac2_chat.send_text("Foobar")
|
||||
ac1_message = ac1._evtracker.wait_next_incoming_message()
|
||||
ac1_clone_message = ac1_clone._evtracker.wait_next_incoming_message()
|
||||
assert "Ephemeral timer: 60\n" in ac1_message.get_message_info()
|
||||
assert "Expires: " not in ac1_clone_message.get_message_info()
|
||||
assert "Ephemeral timer: 60\n" in ac1_message.get_message_info()
|
||||
assert "Expires: " not in ac1_clone_message.get_message_info()
|
||||
|
||||
ac1.mark_seen_messages([ac1_message])
|
||||
assert ac1_message.is_in_seen
|
||||
assert "Expires: " in ac1_message.get_message_info()
|
||||
ev = ac1_clone._evtracker.get_matching("DC_EVENT_MSGS_NOTICED")
|
||||
assert ev.data1 == ac1_clone_chat.id
|
||||
assert ac1_clone_message.is_in_seen
|
||||
# Test that the timer is started on the second device after synchronizing the seen status.
|
||||
assert "Expires: " in ac1_clone_message.get_message_info()
|
||||
|
||||
|
||||
def test_see_new_verified_member_after_going_online(acfactory, tmp_path, lp):
|
||||
"""The test for the bug #3836:
|
||||
- Alice has two devices, the second is offline.
|
||||
|
||||
@@ -54,6 +54,57 @@ def test_configure_unref(tmp_path):
|
||||
lib.dc_context_unref(dc_context)
|
||||
|
||||
|
||||
def test_one_account_send_bcc_setting(acfactory, lp):
|
||||
ac1 = acfactory.new_online_configuring_account()
|
||||
ac2 = acfactory.new_online_configuring_account()
|
||||
ac1_clone = acfactory.new_online_configuring_account(cloned_from=ac1)
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
# test if sent messages are copied to it via BCC.
|
||||
|
||||
chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
self_addr = ac1.get_config("addr")
|
||||
other_addr = ac2.get_config("addr")
|
||||
|
||||
lp.sec("send out message without bcc to ourselves")
|
||||
ac1.set_config("bcc_self", "0")
|
||||
msg_out = chat.send_text("message1")
|
||||
assert not msg_out.is_forwarded()
|
||||
|
||||
# wait for send out (no BCC)
|
||||
ev = ac1._evtracker.get_matching("DC_EVENT_SMTP_MESSAGE_SENT")
|
||||
assert ac1.get_config("bcc_self") == "0"
|
||||
|
||||
# make sure we are not sending message to ourselves
|
||||
assert self_addr not in ev.data2
|
||||
assert other_addr in ev.data2
|
||||
|
||||
lp.sec("ac1: setting bcc_self=1")
|
||||
ac1.set_config("bcc_self", "1")
|
||||
|
||||
lp.sec("send out message with bcc to ourselves")
|
||||
msg_out = chat.send_text("message2")
|
||||
|
||||
# wait for send out (BCC)
|
||||
ev = ac1._evtracker.get_matching("DC_EVENT_SMTP_MESSAGE_SENT")
|
||||
assert ac1.get_config("bcc_self") == "1"
|
||||
|
||||
# Second client receives only second message, but not the first.
|
||||
ev_msg = ac1_clone._evtracker.wait_next_messages_changed()
|
||||
assert ev_msg.text == msg_out.text
|
||||
|
||||
# now make sure we are sending message to ourselves too
|
||||
assert self_addr in ev.data2
|
||||
assert other_addr in ev.data2
|
||||
|
||||
# BCC-self messages are marked as seen by the sender device.
|
||||
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
|
||||
|
||||
# Check that the message is marked as seen on IMAP.
|
||||
ac1.direct_imap.select_folder("Inbox")
|
||||
assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1
|
||||
|
||||
|
||||
def test_send_file_twice_unicode_filename_mangling(tmp_path, acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
@@ -436,6 +487,26 @@ def test_forward_messages(acfactory, lp):
|
||||
assert not chat3.get_messages()
|
||||
|
||||
|
||||
def test_forward_encrypted_to_unencrypted(acfactory, lp):
|
||||
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
|
||||
chat = acfactory.get_protected_chat(ac1, ac2)
|
||||
|
||||
lp.sec("ac1: send encrypted message to ac2")
|
||||
txt = "This should be encrypted"
|
||||
chat.send_text(txt)
|
||||
msg = ac2._evtracker.wait_next_incoming_message()
|
||||
assert msg.text == txt
|
||||
assert msg.is_encrypted()
|
||||
|
||||
lp.sec("ac2: forward message to ac3 unencrypted")
|
||||
unencrypted_chat = ac2.create_chat(ac3)
|
||||
msg_id = msg.id
|
||||
msg2 = unencrypted_chat.send_msg(msg)
|
||||
assert msg2 == msg
|
||||
assert msg.id != msg_id
|
||||
assert not msg.is_encrypted()
|
||||
|
||||
|
||||
def test_forward_own_message(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
@@ -798,6 +869,12 @@ def test_send_and_receive_will_encrypt_decrypt(acfactory, lp):
|
||||
msg3.mark_seen()
|
||||
assert not list(ac1.get_fresh_messages())
|
||||
|
||||
# Test that we do not gossip peer keys in 1-to-1 chat,
|
||||
# as it makes no sense to gossip to peers their own keys.
|
||||
# Gossip is only sent in encrypted messages,
|
||||
# and we sent encrypted msg_back right above.
|
||||
assert chat2b.get_summary()["gossiped_timestamp"] == 0
|
||||
|
||||
lp.sec("create group chat with two members, one of which has no encrypt state")
|
||||
chat = ac1.create_group_chat("encryption test")
|
||||
chat.add_contact(ac2)
|
||||
@@ -807,6 +884,41 @@ def test_send_and_receive_will_encrypt_decrypt(acfactory, lp):
|
||||
ac1._evtracker.get_matching("DC_EVENT_SMTP_MESSAGE_SENT")
|
||||
|
||||
|
||||
def test_gossip_optimization(acfactory, lp):
|
||||
"""Test that gossip timestamp is updated when someone else sends gossip,
|
||||
so we don't have to send gossip ourselves.
|
||||
"""
|
||||
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
|
||||
|
||||
acfactory.introduce_each_other([ac1, ac2])
|
||||
acfactory.introduce_each_other([ac2, ac3])
|
||||
|
||||
lp.sec("ac1 creates a group chat with ac2")
|
||||
group_chat = ac1.create_group_chat("hello")
|
||||
group_chat.add_contact(ac2)
|
||||
msg = group_chat.send_text("hi")
|
||||
|
||||
# No Autocrypt gossip was sent yet.
|
||||
gossiped_timestamp = msg.chat.get_summary()["gossiped_timestamp"]
|
||||
assert gossiped_timestamp == 0
|
||||
|
||||
msg = ac2._evtracker.wait_next_incoming_message()
|
||||
assert msg.is_encrypted()
|
||||
assert msg.text == "hi"
|
||||
|
||||
lp.sec("ac2 adds ac3 to the group")
|
||||
msg.chat.add_contact(ac3)
|
||||
|
||||
lp.sec("ac1 receives message from ac2 and updates gossip timestamp")
|
||||
msg = ac1._evtracker.wait_next_incoming_message()
|
||||
assert msg.is_encrypted()
|
||||
|
||||
# ac1 has updated the gossip timestamp even though no gossip was sent by ac1.
|
||||
# ac1 does not need to send gossip because ac2 already did it.
|
||||
gossiped_timestamp = msg.chat.get_summary()["gossiped_timestamp"]
|
||||
assert gossiped_timestamp == int(msg.time_sent.timestamp())
|
||||
|
||||
|
||||
def test_send_first_message_as_long_unicode_with_cr(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
|
||||
@@ -819,7 +931,7 @@ def test_send_first_message_as_long_unicode_with_cr(acfactory, lp):
|
||||
" wrapped using format=flowed and unwrapped on the receiver"
|
||||
)
|
||||
msg_out = chat.send_text(text1)
|
||||
assert msg_out.is_encrypted()
|
||||
assert not msg_out.is_encrypted()
|
||||
|
||||
lp.sec("wait for ac2 to receive multi-line non-unicode message")
|
||||
msg_in = ac2._evtracker.wait_next_incoming_message()
|
||||
@@ -828,7 +940,7 @@ def test_send_first_message_as_long_unicode_with_cr(acfactory, lp):
|
||||
lp.sec("sending multi-line unicode text message from ac1 to ac2")
|
||||
text2 = "äalis\nthis is ßßÄ"
|
||||
msg_out = chat.send_text(text2)
|
||||
assert msg_out.is_encrypted()
|
||||
assert not msg_out.is_encrypted()
|
||||
|
||||
lp.sec("wait for ac2 to receive multi-line unicode message")
|
||||
msg_in = ac2._evtracker.wait_next_incoming_message()
|
||||
@@ -1234,7 +1346,7 @@ def test_qr_email_capitalization(acfactory, lp):
|
||||
lp.sec("ac1 joins a verified group via a QR code")
|
||||
ac1_chat = ac1.qr_join_chat(qr)
|
||||
msg = ac1._evtracker.wait_next_incoming_message()
|
||||
assert msg.text == "Member Me added by {}.".format(ac3.get_config("addr"))
|
||||
assert msg.text == "Member Me ({}) added by {}.".format(ac1.get_config("addr"), ac3.get_config("addr"))
|
||||
assert len(ac1_chat.get_contacts()) == 2
|
||||
|
||||
lp.sec("ac2 joins a verified group via a QR code")
|
||||
@@ -1333,7 +1445,7 @@ def test_add_remove_member_remote_events(acfactory, lp):
|
||||
lp.sec("ac1: add address2")
|
||||
# note that if the above create_chat() would not
|
||||
# happen we would not receive a proper member_added event
|
||||
contact2 = chat.add_contact(ac3)
|
||||
contact2 = chat.add_contact(ac3_addr)
|
||||
ev = in_list.get()
|
||||
assert ev.action == "chat-modified"
|
||||
ev = in_list.get()
|
||||
@@ -1502,6 +1614,15 @@ def test_connectivity(acfactory, lp):
|
||||
assert len(msgs) == 2
|
||||
assert msgs[1].text == "Hi 2"
|
||||
|
||||
lp.sec("Test that the connectivity is NOT_CONNECTED if the password is wrong")
|
||||
|
||||
ac1.set_config("configured_mail_pw", "abc")
|
||||
ac1.stop_io()
|
||||
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_NOT_CONNECTED)
|
||||
ac1.start_io()
|
||||
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_CONNECTING)
|
||||
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_NOT_CONNECTED)
|
||||
|
||||
|
||||
def test_fetch_deleted_msg(acfactory, lp):
|
||||
"""This is a regression test: Messages with \\Deleted flag were downloaded again and again,
|
||||
@@ -1764,7 +1885,9 @@ def test_name_changes(acfactory):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
ac1.set_config("displayname", "Account 1")
|
||||
|
||||
chat12 = acfactory.get_accepted_chat(ac1, ac2)
|
||||
# Similar to acfactory.get_accepted_chat, but without setting the contact name.
|
||||
ac2.create_contact(ac1.get_config("addr")).create_chat()
|
||||
chat12 = ac1.create_contact(ac2.get_config("addr")).create_chat()
|
||||
contact = None
|
||||
|
||||
def update_name():
|
||||
@@ -1929,6 +2052,23 @@ def test_scan_folders(acfactory, lp, folder, move, expected_destination):
|
||||
assert len(ac1.direct_imap.get_all_messages()) == 0
|
||||
|
||||
|
||||
def test_delete_deltachat_folder(acfactory):
|
||||
"""Test that DeltaChat folder is recreated if user deletes it manually."""
|
||||
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
|
||||
ac2 = acfactory.new_online_configuring_account()
|
||||
acfactory.wait_configured(ac1)
|
||||
|
||||
ac1.direct_imap.conn.folder.delete("DeltaChat")
|
||||
assert "DeltaChat" not in ac1.direct_imap.list_folders()
|
||||
acfactory.bring_accounts_online()
|
||||
|
||||
ac2.create_chat(ac1).send_text("hello")
|
||||
msg = ac1._evtracker.wait_next_incoming_message()
|
||||
assert msg.text == "hello"
|
||||
|
||||
assert "DeltaChat" in ac1.direct_imap.list_folders()
|
||||
|
||||
|
||||
def test_archived_muted_chat(acfactory, lp):
|
||||
"""If an archived and muted chat receives a new message, DC_EVENT_MSGS_CHANGED for
|
||||
DC_CHAT_ID_ARCHIVED_LINK must be generated if the chat had only seen messages previously.
|
||||
|
||||
@@ -45,7 +45,7 @@ deps =
|
||||
pygments
|
||||
restructuredtext_lint
|
||||
commands =
|
||||
ruff format --diff setup.py src/deltachat examples/ tests/
|
||||
ruff format --quiet --diff setup.py src/deltachat examples/ tests/
|
||||
ruff check src/deltachat tests/ examples/
|
||||
rst-lint --encoding 'utf-8' README.rst
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025-04-24
|
||||
2025-03-13
|
||||
@@ -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.86.0
|
||||
RUST_VERSION=1.84.1
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
|
||||
|
||||
@@ -215,7 +215,7 @@ if __name__ == "__main__":
|
||||
" Config, ConfigDefault, Oauth2Authorizer, Provider, ProviderOptions, Server, Status,\n"
|
||||
"};\n"
|
||||
"use std::collections::HashMap;\n\n"
|
||||
"use std::sync::LazyLock;\n\n"
|
||||
"use once_cell::sync::Lazy;\n\n"
|
||||
)
|
||||
|
||||
process_dir(Path(sys.argv[1]))
|
||||
@@ -224,7 +224,7 @@ if __name__ == "__main__":
|
||||
out_all += out_domains
|
||||
out_all += "];\n\n"
|
||||
|
||||
out_all += "pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider>> = LazyLock::new(|| HashMap::from([\n"
|
||||
out_all += "pub(crate) static PROVIDER_IDS: Lazy<HashMap<&'static str, &'static Provider>> = Lazy::new(|| HashMap::from([\n"
|
||||
out_all += out_ids
|
||||
out_all += "]));\n\n"
|
||||
|
||||
@@ -233,8 +233,8 @@ if __name__ == "__main__":
|
||||
else:
|
||||
now = datetime.datetime.fromisoformat(sys.argv[2])
|
||||
out_all += (
|
||||
"pub static _PROVIDER_UPDATED: LazyLock<chrono::NaiveDate> = "
|
||||
"LazyLock::new(|| chrono::NaiveDate::from_ymd_opt("
|
||||
"pub static _PROVIDER_UPDATED: Lazy<chrono::NaiveDate> = "
|
||||
"Lazy::new(|| chrono::NaiveDate::from_ymd_opt("
|
||||
+ str(now.year)
|
||||
+ ", "
|
||||
+ str(now.month)
|
||||
|
||||
90
spec.md
90
spec.md
@@ -1,10 +1,10 @@
|
||||
# Chatmail Specification
|
||||
# chat-mail specification
|
||||
|
||||
Version: 0.36.0
|
||||
Version: 0.35.0
|
||||
Status: In-progress
|
||||
Format: [Semantic Line Breaks](https://sembr.org/)
|
||||
|
||||
This document roughly describes how chatmail
|
||||
This document roughly describes how chat-mail
|
||||
apps use the standard e-mail system
|
||||
to implement typical messenger functions.
|
||||
|
||||
@@ -18,8 +18,6 @@ to implement typical messenger functions.
|
||||
- [Add and remove members](#add-and-remove-members)
|
||||
- [Change group name](#change-group-name)
|
||||
- [Set group image](#set-group-image)
|
||||
- [Request editing](#request-editing)
|
||||
- [Request deletion](#request-deletion)
|
||||
- [Set profile image](#set-profile-image)
|
||||
- [Locations](#locations)
|
||||
- [User locations](#user-locations)
|
||||
@@ -306,84 +304,6 @@ To save data, it is RECOMMENDED
|
||||
to add a `Chat-Group-Avatar` only on image changes.
|
||||
|
||||
|
||||
# Request editing
|
||||
|
||||
To request recipients to edit the text of an already sent message,
|
||||
the messenger MUST set the header `Chat-Edit`
|
||||
with value set to the message-id of the message to edit
|
||||
and the body to the new message text.
|
||||
|
||||
The body MAY be prefixed by a quote
|
||||
and the emoji "✏️" directly before the new text.
|
||||
Both MUST be skipped by the recipient.
|
||||
|
||||
Receiving messengers MUST look up the message-id from `Chat-Edit`,
|
||||
replace the text and MAY indicate the edit in the UI.
|
||||
|
||||
The new message text MUST NOT be empty.
|
||||
It is not possible to edit images or other attachments, including HTML messages.
|
||||
However, they can be deleted for everyone.
|
||||
|
||||
Example:
|
||||
|
||||
From: sender@domain
|
||||
To: rcpt@domain
|
||||
Chat-Version: 1.0
|
||||
Message-ID: 00001@domain
|
||||
Content-Type: text/plain
|
||||
|
||||
Hello wordl!
|
||||
|
||||
The typo from the message above can be fixed by the following message:
|
||||
|
||||
From: sender@domain
|
||||
To: rcpt@domain
|
||||
Chat-Version: 1.0
|
||||
Chat-Edit: 00001@domain
|
||||
In-Reply-To: 00001@domain
|
||||
Message-ID: 00002@domain
|
||||
Content-Type: text/plain
|
||||
|
||||
On 2025-03-27, sender@domain wrote:
|
||||
> Hello wordl!
|
||||
|
||||
✏️Hello world!
|
||||
|
||||
|
||||
# Request deletion
|
||||
|
||||
To request recipient to delete a message,
|
||||
the messenger MUST set the header `Chat-Delete`
|
||||
with the value set to the message-id of the message to delete.
|
||||
|
||||
Receiving messengers MUST look up the message-id, delete the corresponding message
|
||||
and MAY indicating the deletion in the UI.
|
||||
|
||||
The sender MUST set the body to any, non-empty text.
|
||||
The receiver MUST ignore the body.
|
||||
|
||||
Example:
|
||||
|
||||
From: sender@domain
|
||||
To: rcpt@domain
|
||||
Chat-Version: 1.0
|
||||
Message-ID: 00003@domain
|
||||
Content-Type: text/plain
|
||||
|
||||
reminder for my pin: 1234
|
||||
|
||||
The message above can be requested for deletion by the following message:
|
||||
|
||||
From: sender@domain
|
||||
To: rcpt@domain
|
||||
Chat-Version: 1.0
|
||||
Chat-Delete: 00003@domain
|
||||
Message-ID: 00004@domain
|
||||
Content-Type: text/plain
|
||||
|
||||
foo
|
||||
|
||||
|
||||
# Set profile image
|
||||
|
||||
A user MAY have a profile-image that MAY be distributed to their contacts.
|
||||
@@ -455,7 +375,7 @@ eg. forwarded from a normal MUA.
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document addr="foo@domain">
|
||||
<Document addr="ndh@deltachat.de">
|
||||
<Placemark>
|
||||
<Timestamp><when>2020-01-11T20:40:19Z</when></Timestamp>
|
||||
<Point><coordinates accuracy="1.2">1.234,5.678</coordinates></Point>
|
||||
@@ -622,4 +542,4 @@ We define the effective date of a message
|
||||
as the sending time of the message as indicated by its Date header,
|
||||
or the time of first receipt if that date is in the future or unavailable.
|
||||
|
||||
Copyright © Chatmail contributors.
|
||||
Copyright © 2017-2021 Delta Chat contributors.
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, ensure, Context as _, Result};
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -73,7 +73,9 @@ impl Accounts {
|
||||
let config_file = dir.join(CONFIG_NAME);
|
||||
ensure!(config_file.exists(), "{:?} does not exist", config_file);
|
||||
|
||||
let config = Config::from_file(config_file, writable).await?;
|
||||
let config = Config::from_file(config_file, writable)
|
||||
.await
|
||||
.context("failed to load accounts config")?;
|
||||
let events = Events::new();
|
||||
let stockstrings = StockStrings::new();
|
||||
let push_subscriber = PushSubscriber::new();
|
||||
@@ -458,9 +460,7 @@ impl Config {
|
||||
rx.await?;
|
||||
Ok(())
|
||||
});
|
||||
if locked_rx.await.is_err() {
|
||||
bail!("Delta Chat is already running. To use Delta Chat, you must first close the existing Delta Chat process, or restart your device. (accounts.lock file is already locked)");
|
||||
};
|
||||
locked_rx.await?;
|
||||
Ok(Some(lock_task))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use mailparse::MailHeaderMap;
|
||||
use mailparse::ParsedMail;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
@@ -107,8 +107,7 @@ fn remove_comments(header: &str) -> Cow<'_, str> {
|
||||
// In Pomsky, this is:
|
||||
// "(" Codepoint* lazy ")"
|
||||
// See https://playground.pomsky-lang.org/?text=%22(%22%20Codepoint*%20lazy%20%22)%22
|
||||
static RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"\([\s\S]*?\)").unwrap());
|
||||
static RE: Lazy<regex::Regex> = Lazy::new(|| regex::Regex::new(r"\([\s\S]*?\)").unwrap());
|
||||
|
||||
RE.replace_all(header, " ")
|
||||
}
|
||||
|
||||
17
src/blob.rs
17
src/blob.rs
@@ -193,7 +193,6 @@ impl<'a> BlobObject<'a> {
|
||||
/// Note that this is NOT the user-visible filename,
|
||||
/// which is only stored in Param::Filename on the message.
|
||||
///
|
||||
#[allow(rustdoc::private_intra_doc_links)]
|
||||
/// [Params]: crate::param::Params
|
||||
pub fn as_name(&self) -> &str {
|
||||
&self.name
|
||||
@@ -252,7 +251,6 @@ impl<'a> BlobObject<'a> {
|
||||
Ok(blob.as_name().to_string())
|
||||
}
|
||||
|
||||
/// Recode image to avatar size.
|
||||
pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> {
|
||||
let (img_wh, max_bytes) =
|
||||
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
|
||||
@@ -432,20 +430,7 @@ impl<'a> BlobObject<'a> {
|
||||
if mem::take(&mut add_white_bg) {
|
||||
self::add_white_bg(&mut img);
|
||||
}
|
||||
|
||||
// resize() results in often slightly better quality,
|
||||
// however, comes at high price of being 4+ times slower than thumbnail().
|
||||
// for a typical camera image that is sent, this may be a change from "instant" (500ms) to "long time waiting" (3s).
|
||||
// as we do not have recoding in background while chat has already a preview,
|
||||
// we vote for speed.
|
||||
// exception is the avatar image: this is far more often sent than recoded,
|
||||
// usually has less pixels by cropping, UI that needs to wait anyways,
|
||||
// and also benefits from slightly better (5%) encoding of Triangle-filtered images.
|
||||
let new_img = if is_avatar {
|
||||
img.resize(img_wh, img_wh, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
img.thumbnail(img_wh, img_wh)
|
||||
};
|
||||
let new_img = img.thumbnail(img_wh, img_wh);
|
||||
|
||||
if encoded_img_exceeds_bytes(
|
||||
context,
|
||||
|
||||
@@ -174,7 +174,7 @@ async fn test_selfavatar_outside_blobdir() {
|
||||
let avatar_blob = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
|
||||
let avatar_path = Path::new(&avatar_blob);
|
||||
assert!(
|
||||
avatar_blob.ends_with("7dde69e06b5ae6c27520a436bbfd65b.jpg"),
|
||||
avatar_blob.ends_with("009161310a6afc319163e4bcabd23b9.jpg"),
|
||||
"The avatar filename should be its hash, put instead it's {avatar_blob}"
|
||||
);
|
||||
let scaled_avatar_size = file_size(avatar_path).await;
|
||||
@@ -226,7 +226,7 @@ async fn test_selfavatar_in_blobdir() {
|
||||
.unwrap();
|
||||
let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
|
||||
assert!(
|
||||
avatar_cfg.ends_with("d57cb5ce5f371531b6e1fb17b6dd1af.png"),
|
||||
avatar_cfg.ends_with("ec054c444a5755adf2b0aaea40209f2.png"),
|
||||
"Avatar file name {avatar_cfg} should end with its hash"
|
||||
);
|
||||
|
||||
|
||||
161
src/chat.rs
161
src/chat.rs
@@ -130,7 +130,8 @@ pub(crate) enum CantSendReason {
|
||||
/// Not a member of the chat.
|
||||
NotAMember,
|
||||
|
||||
/// Temporary state for 1:1 chats while SecureJoin is in progress.
|
||||
/// Temporary state for 1:1 chats while SecureJoin is in progress, after a timeout sending
|
||||
/// messages (incl. unencrypted if we don't yet know the contact's pubkey) is allowed.
|
||||
SecurejoinWait,
|
||||
}
|
||||
|
||||
@@ -581,18 +582,7 @@ impl ChatId {
|
||||
ProtectionStatus::Unprotected => SystemMessage::ChatProtectionDisabled,
|
||||
ProtectionStatus::ProtectionBroken => SystemMessage::ChatProtectionDisabled,
|
||||
};
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
self,
|
||||
&text,
|
||||
cmd,
|
||||
timestamp_sort,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
add_info_msg_with_cmd(context, self, &text, cmd, timestamp_sort, None, None, None).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1090,8 +1080,7 @@ impl ChatId {
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Returns timestamp of the latest message in the chat,
|
||||
/// including hidden messages or a draft if there is one.
|
||||
/// Returns timestamp of the latest message in the chat.
|
||||
pub(crate) async fn get_timestamp(self, context: &Context) -> Result<Option<i64>> {
|
||||
let timestamp = context
|
||||
.sql
|
||||
@@ -1376,10 +1365,41 @@ impl ChatId {
|
||||
}
|
||||
|
||||
pub(crate) async fn reset_gossiped_timestamp(self, context: &Context) -> Result<()> {
|
||||
self.set_gossiped_timestamp(context, 0).await
|
||||
}
|
||||
|
||||
/// Get timestamp of the last gossip sent in the chat.
|
||||
/// Zero return value means that gossip was never sent.
|
||||
pub async fn get_gossiped_timestamp(self, context: &Context) -> Result<i64> {
|
||||
let timestamp: Option<i64> = context
|
||||
.sql
|
||||
.query_get_value("SELECT gossiped_timestamp FROM chats WHERE id=?;", (self,))
|
||||
.await?;
|
||||
Ok(timestamp.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_gossiped_timestamp(
|
||||
self,
|
||||
context: &Context,
|
||||
timestamp: i64,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
!self.is_special(),
|
||||
"can not set gossiped timestamp for special chats"
|
||||
);
|
||||
info!(
|
||||
context,
|
||||
"Set gossiped_timestamp for chat {} to {}.", self, timestamp,
|
||||
);
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute("DELETE FROM gossip_timestamp WHERE chat_id=?", (self,))
|
||||
.execute(
|
||||
"UPDATE chats SET gossiped_timestamp=? WHERE id=?;",
|
||||
(timestamp, self),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1715,8 +1735,8 @@ impl Chat {
|
||||
|
||||
/// Returns the remaining timeout for the 1:1 chat in-progress SecureJoin.
|
||||
///
|
||||
/// If the timeout has expired, adds an info message with additional information.
|
||||
/// See also [`CantSendReason::SecurejoinWait`].
|
||||
/// If the timeout has expired, notifies the user that sending messages is possible. See also
|
||||
/// [`CantSendReason::SecurejoinWait`].
|
||||
pub(crate) async fn check_securejoin_wait(
|
||||
&self,
|
||||
context: &Context,
|
||||
@@ -1725,19 +1745,16 @@ impl Chat {
|
||||
if self.typ != Chattype::Single || self.protected != ProtectionStatus::Unprotected {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// chat is single and unprotected:
|
||||
// get last info message of type SecurejoinWait or SecurejoinWaitTimeout
|
||||
let (mut param_wait, mut param_timeout) = (Params::new(), Params::new());
|
||||
param_wait.set_cmd(SystemMessage::SecurejoinWait);
|
||||
param_timeout.set_cmd(SystemMessage::SecurejoinWaitTimeout);
|
||||
let (param_wait, param_timeout) = (param_wait.to_string(), param_timeout.to_string());
|
||||
let (mut param0, mut param1) = (Params::new(), Params::new());
|
||||
param0.set_cmd(SystemMessage::SecurejoinWait);
|
||||
param1.set_cmd(SystemMessage::SecurejoinWaitTimeout);
|
||||
let (param0, param1) = (param0.to_string(), param1.to_string());
|
||||
let Some((param, ts_sort, ts_start)) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT param, timestamp, timestamp_sent FROM msgs WHERE id=\
|
||||
(SELECT MAX(id) FROM msgs WHERE chat_id=? AND param IN (?, ?))",
|
||||
(self.id, ¶m_wait, ¶m_timeout),
|
||||
(self.id, ¶m0, ¶m1),
|
||||
|row| {
|
||||
let param: String = row.get(0)?;
|
||||
let ts_sort: i64 = row.get(1)?;
|
||||
@@ -1749,10 +1766,9 @@ impl Chat {
|
||||
else {
|
||||
return Ok(0);
|
||||
};
|
||||
if param == param_timeout {
|
||||
if param == param1 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let now = time();
|
||||
// Don't await SecureJoin if the clock was set back.
|
||||
if ts_start <= now {
|
||||
@@ -1766,7 +1782,7 @@ impl Chat {
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
self.id,
|
||||
&stock_str::securejoin_takes_longer(context).await,
|
||||
&stock_str::securejoin_wait_timeout(context).await,
|
||||
SystemMessage::SecurejoinWaitTimeout,
|
||||
// Use the sort timestamp of the "please wait" message, this way the added message is
|
||||
// never sorted below the protection message if the SecureJoin finishes in parallel.
|
||||
@@ -1774,7 +1790,6 @@ impl Chat {
|
||||
Some(now),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
context.emit_event(EventType::ChatModified(self.id));
|
||||
@@ -1883,6 +1898,7 @@ impl Chat {
|
||||
name: self.name.clone(),
|
||||
archived: self.visibility == ChatVisibility::Archived,
|
||||
param: self.param.to_string(),
|
||||
gossiped_timestamp: self.id.get_gossiped_timestamp(context).await?,
|
||||
is_sending_locations: self.is_sending_locations,
|
||||
color: self.get_color(context).await?,
|
||||
profile_image: self
|
||||
@@ -1973,7 +1989,13 @@ impl Chat {
|
||||
if let Some(member_list_timestamp) = self.param.get_i64(Param::MemberListTimestamp) {
|
||||
Ok(member_list_timestamp)
|
||||
} else {
|
||||
Ok(self.id.created_timestamp(context).await?)
|
||||
let creation_timestamp: i64 = context
|
||||
.sql
|
||||
.query_get_value("SELECT created_timestamp FROM chats WHERE id=?", (self.id,))
|
||||
.await
|
||||
.context("SQL error querying created_timestamp")?
|
||||
.context("Chat not found")?;
|
||||
Ok(creation_timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2005,9 +2027,7 @@ impl Chat {
|
||||
let mut to_id = 0;
|
||||
let mut location_id = 0;
|
||||
|
||||
if msg.rfc724_mid.is_empty() {
|
||||
msg.rfc724_mid = create_outgoing_rfc724_mid();
|
||||
}
|
||||
let new_rfc724_mid = create_outgoing_rfc724_mid();
|
||||
|
||||
if self.typ == Chattype::Single {
|
||||
if let Some(id) = context
|
||||
@@ -2030,9 +2050,7 @@ impl Chat {
|
||||
&& self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1
|
||||
{
|
||||
msg.param.set_int(Param::AttachGroupImage, 1);
|
||||
self.param
|
||||
.remove(Param::Unpromoted)
|
||||
.set_i64(Param::GroupNameTimestamp, timestamp);
|
||||
self.param.remove(Param::Unpromoted);
|
||||
self.update_param(context).await?;
|
||||
// TODO: Remove this compat code needed because Core <= v1.143:
|
||||
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also
|
||||
@@ -2105,7 +2123,7 @@ impl Chat {
|
||||
if references_vec.is_empty() {
|
||||
// As a fallback, use our Message-ID,
|
||||
// same as in the case of top-level message.
|
||||
new_references = msg.rfc724_mid.clone();
|
||||
new_references = new_rfc724_mid.clone();
|
||||
} else {
|
||||
new_references = references_vec.join(" ");
|
||||
}
|
||||
@@ -2115,7 +2133,7 @@ impl Chat {
|
||||
// This allows us to identify replies to our message even if
|
||||
// email server such as Outlook changes `Message-ID:` header.
|
||||
// MUAs usually keep the first Message-ID in `References:` header unchanged.
|
||||
new_references = msg.rfc724_mid.clone();
|
||||
new_references = new_rfc724_mid.clone();
|
||||
}
|
||||
|
||||
// add independent location to database
|
||||
@@ -2183,6 +2201,7 @@ impl Chat {
|
||||
|
||||
msg.chat_id = self.id;
|
||||
msg.from_id = ContactId::SELF;
|
||||
msg.rfc724_mid = new_rfc724_mid;
|
||||
msg.timestamp_sort = timestamp;
|
||||
|
||||
// add message to the database
|
||||
@@ -2426,6 +2445,9 @@ pub struct ChatInfo {
|
||||
/// This is the string-serialised version of `Params` currently.
|
||||
pub param: String,
|
||||
|
||||
/// Last time this client sent autocrypt gossip headers to this chat.
|
||||
pub gossiped_timestamp: i64,
|
||||
|
||||
/// Whether this chat is currently sending location-stream messages.
|
||||
pub is_sending_locations: bool,
|
||||
|
||||
@@ -2987,12 +3009,6 @@ async fn prepare_send_msg(
|
||||
///
|
||||
/// 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>> {
|
||||
if msg.param.get_cmd() == SystemMessage::GroupNameChanged {
|
||||
msg.chat_id
|
||||
.update_timestamp(context, Param::GroupNameTimestamp, msg.timestamp_sort)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let needs_encryption = msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default();
|
||||
let mimefactory = MimeFactory::from_msg(context, msg.clone()).await?;
|
||||
let attach_selfavatar = mimefactory.attach_selfavatar;
|
||||
@@ -3013,10 +3029,10 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
// disabled by default is fine.
|
||||
//
|
||||
// `from` must be the last addr, see `receive_imf_inner()` why.
|
||||
recipients.retain(|x| x.to_lowercase() != lowercase_from);
|
||||
if (context.get_config_bool(Config::BccSelf).await?
|
||||
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage)
|
||||
&& (context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty())
|
||||
if context.get_config_bool(Config::BccSelf).await?
|
||||
&& !recipients
|
||||
.iter()
|
||||
.any(|x| x.to_lowercase() == lowercase_from)
|
||||
{
|
||||
recipients.push(from);
|
||||
}
|
||||
@@ -3038,6 +3054,11 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
msg.state = MessageState::OutDelivered;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if msg.param.get_cmd() == SystemMessage::GroupNameChanged {
|
||||
msg.chat_id
|
||||
.update_timestamp(context, Param::GroupNameTimestamp, msg.timestamp_sort)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let rendered_msg = match mimefactory.render(context).await {
|
||||
Ok(res) => Ok(res),
|
||||
@@ -3064,6 +3085,10 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
|
||||
let now = smeared_time(context);
|
||||
|
||||
if rendered_msg.is_gossiped {
|
||||
msg.chat_id.set_gossiped_timestamp(context, now).await?;
|
||||
}
|
||||
|
||||
if rendered_msg.last_added_location_id.is_some() {
|
||||
if let Err(err) = location::set_kml_sent_timestamp(context, msg.chat_id, now).await {
|
||||
error!(context, "Failed to set kml sent_timestamp: {err:#}.");
|
||||
@@ -3847,7 +3872,7 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
) -> Result<bool> {
|
||||
ensure!(!chat_id.is_special(), "can not add member to special chats");
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
let mut msg = Message::new(Viewtype::default());
|
||||
let mut msg = Message::default();
|
||||
|
||||
chat_id.reset_gossiped_timestamp(context).await?;
|
||||
|
||||
@@ -3878,9 +3903,7 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
|
||||
let sync_qr_code_tokens;
|
||||
if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
|
||||
chat.param
|
||||
.remove(Param::Unpromoted)
|
||||
.set_i64(Param::GroupNameTimestamp, smeared_time(context));
|
||||
chat.param.remove(Param::Unpromoted);
|
||||
chat.update_param(context).await?;
|
||||
sync_qr_code_tokens = true;
|
||||
} else {
|
||||
@@ -3923,8 +3946,6 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
msg.param.set_cmd(SystemMessage::MemberAddedToGroup);
|
||||
msg.param.set(Param::Arg, contact_addr);
|
||||
msg.param.set_int(Param::Arg2, from_handshake.into());
|
||||
msg.param
|
||||
.set_int(Param::ContactAddedRemoved, contact.id.to_u32() as i32);
|
||||
send_msg(context, chat_id, &mut msg).await?;
|
||||
|
||||
sync = Nosync;
|
||||
@@ -4078,7 +4099,7 @@ pub async fn remove_contact_from_chat(
|
||||
"Cannot remove special contact"
|
||||
);
|
||||
|
||||
let mut msg = Message::new(Viewtype::default());
|
||||
let mut msg = Message::default();
|
||||
|
||||
let chat = Chat::load_from_db(context, chat_id).await?;
|
||||
if chat.typ == Chattype::Group || chat.typ == Chattype::Broadcast {
|
||||
@@ -4122,8 +4143,6 @@ pub async fn remove_contact_from_chat(
|
||||
}
|
||||
msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup);
|
||||
msg.param.set(Param::Arg, contact.get_addr().to_lowercase());
|
||||
msg.param
|
||||
.set(Param::ContactAddedRemoved, contact.id.to_u32() as i32);
|
||||
let res = send_msg(context, chat_id, &mut msg).await;
|
||||
if contact_id == ContactId::SELF {
|
||||
res?;
|
||||
@@ -4185,7 +4204,7 @@ async fn rename_ex(
|
||||
ensure!(!chat_id.is_special(), "Invalid chat ID");
|
||||
|
||||
let chat = Chat::load_from_db(context, chat_id).await?;
|
||||
let mut msg = Message::new(Viewtype::default());
|
||||
let mut msg = Message::default();
|
||||
|
||||
if chat.typ == Chattype::Group
|
||||
|| chat.typ == Chattype::Mailinglist
|
||||
@@ -4341,18 +4360,15 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
|
||||
msg.param.remove(Param::WebxdcDocumentTimestamp);
|
||||
msg.param.remove(Param::WebxdcSummary);
|
||||
msg.param.remove(Param::WebxdcSummaryTimestamp);
|
||||
msg.param.remove(Param::IsEdited);
|
||||
msg.in_reply_to = None;
|
||||
|
||||
// do not leak data as group names; a default subject is generated by mimefactory
|
||||
msg.subject = "".to_string();
|
||||
|
||||
msg.state = MessageState::OutPending;
|
||||
msg.rfc724_mid = create_outgoing_rfc724_mid();
|
||||
let new_msg_id = chat
|
||||
.prepare_msg_raw(context, &mut msg, None, curr_timestamp)
|
||||
.await?;
|
||||
|
||||
curr_timestamp += 1;
|
||||
if !create_send_msg_jobs(context, &mut msg).await?.is_empty() {
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
@@ -4404,7 +4420,7 @@ pub(crate) async fn save_copy_in_self_talk(
|
||||
bail!("message already saved.");
|
||||
}
|
||||
|
||||
let copy_fields = "from_id, to_id, timestamp_sent, timestamp_rcvd, type, txt, \
|
||||
let copy_fields = "from_id, to_id, timestamp_sent, timestamp_rcvd, type, txt, txt_raw, \
|
||||
mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg";
|
||||
let row_id = context
|
||||
.sql
|
||||
@@ -4598,7 +4614,17 @@ pub async fn add_device_msg_with_importance(
|
||||
// makes sure, the added message is the last one,
|
||||
// even if the date is wrong (useful esp. when warning about bad dates)
|
||||
let mut timestamp_sort = timestamp_sent;
|
||||
if let Some(last_msg_time) = chat_id.get_timestamp(context).await? {
|
||||
if let Some(last_msg_time) = context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT MAX(timestamp)
|
||||
FROM msgs
|
||||
WHERE chat_id=?
|
||||
HAVING COUNT(*) > 0",
|
||||
(chat_id,),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
if timestamp_sort <= last_msg_time {
|
||||
timestamp_sort = last_msg_time + 1;
|
||||
}
|
||||
@@ -4725,17 +4751,13 @@ pub(crate) async fn add_info_msg_with_cmd(
|
||||
timestamp_sent_rcvd: Option<i64>,
|
||||
parent: Option<&Message>,
|
||||
from_id: Option<ContactId>,
|
||||
added_removed_id: Option<ContactId>,
|
||||
) -> Result<MsgId> {
|
||||
let rfc724_mid = create_outgoing_rfc724_mid();
|
||||
let ephemeral_timer = chat_id.get_ephemeral_timer(context).await?;
|
||||
|
||||
let mut param = Params::new();
|
||||
if cmd != SystemMessage::Unknown {
|
||||
param.set_cmd(cmd);
|
||||
}
|
||||
if let Some(contact_id) = added_removed_id {
|
||||
param.set(Param::ContactAddedRemoved, contact_id.to_u32().to_string());
|
||||
param.set_cmd(cmd)
|
||||
}
|
||||
|
||||
let row_id =
|
||||
@@ -4783,7 +4805,6 @@ pub(crate) async fn add_info_msg(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
//! # Chat list module.
|
||||
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use std::sync::LazyLock;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::chat::{update_special_chat_names, Chat, ChatId, ChatVisibility};
|
||||
use crate::constants::{
|
||||
@@ -17,8 +17,8 @@ use crate::summary::Summary;
|
||||
use crate::tools::IsNoneOrEmpty;
|
||||
|
||||
/// Regex to find out if a query should filter by unread messages.
|
||||
pub static IS_UNREAD_FILTER: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"\bis:unread\b").unwrap());
|
||||
pub static IS_UNREAD_FILTER: Lazy<regex::Regex> =
|
||||
Lazy::new(|| regex::Regex::new(r"\bis:unread\b").unwrap());
|
||||
|
||||
/// An object representing a single chatlist in memory.
|
||||
///
|
||||
@@ -492,20 +492,19 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_try_load() {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
let chat_id1 = create_group_chat(bob, ProtectionStatus::Unprotected, "a chat")
|
||||
let t = TestContext::new_bob().await;
|
||||
let chat_id1 = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat")
|
||||
.await
|
||||
.unwrap();
|
||||
let chat_id2 = create_group_chat(bob, ProtectionStatus::Unprotected, "b chat")
|
||||
let chat_id2 = create_group_chat(&t, ProtectionStatus::Unprotected, "b chat")
|
||||
.await
|
||||
.unwrap();
|
||||
let chat_id3 = create_group_chat(bob, ProtectionStatus::Unprotected, "c chat")
|
||||
let chat_id3 = create_group_chat(&t, ProtectionStatus::Unprotected, "c chat")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// check that the chatlist starts with the most recent message
|
||||
let chats = Chatlist::try_load(bob, 0, None, None).await.unwrap();
|
||||
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
|
||||
assert_eq!(chats.len(), 3);
|
||||
assert_eq!(chats.get_chat_id(0).unwrap(), chat_id3);
|
||||
assert_eq!(chats.get_chat_id(1).unwrap(), chat_id2);
|
||||
@@ -521,49 +520,51 @@ mod tests {
|
||||
// 2s here.
|
||||
for chat_id in &[chat_id1, chat_id3, chat_id2] {
|
||||
let mut msg = Message::new_text("hello".to_string());
|
||||
chat_id.set_draft(bob, Some(&mut msg)).await.unwrap();
|
||||
chat_id.set_draft(&t, Some(&mut msg)).await.unwrap();
|
||||
}
|
||||
|
||||
let chats = Chatlist::try_load(bob, 0, None, None).await.unwrap();
|
||||
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
|
||||
assert_eq!(chats.get_chat_id(0).unwrap(), chat_id2);
|
||||
|
||||
// check chatlist query and archive functionality
|
||||
let chats = Chatlist::try_load(bob, 0, Some("b"), None).await.unwrap();
|
||||
let chats = Chatlist::try_load(&t, 0, Some("b"), None).await.unwrap();
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
// receive a message from alice
|
||||
let alice = &tcm.alice().await;
|
||||
let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "alice chat")
|
||||
let alice = TestContext::new_alice().await;
|
||||
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "alice chat")
|
||||
.await
|
||||
.unwrap();
|
||||
add_contact_to_chat(
|
||||
alice,
|
||||
&alice,
|
||||
alice_chat_id,
|
||||
alice.add_or_lookup_contact_id(bob).await,
|
||||
Contact::create(&alice, "bob", "bob@example.net")
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
send_text_msg(alice, alice_chat_id, "hi".into())
|
||||
send_text_msg(&alice, alice_chat_id, "hi".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let sent_msg = alice.pop_sent_msg().await;
|
||||
|
||||
bob.recv_msg(&sent_msg).await;
|
||||
let chats = Chatlist::try_load(bob, 0, Some("is:unread"), None)
|
||||
t.recv_msg(&sent_msg).await;
|
||||
let chats = Chatlist::try_load(&t, 0, Some("is:unread"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
let chats = Chatlist::try_load(bob, DC_GCL_ARCHIVED_ONLY, None, None)
|
||||
let chats = Chatlist::try_load(&t, DC_GCL_ARCHIVED_ONLY, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(chats.len(), 0);
|
||||
|
||||
chat_id1
|
||||
.set_visibility(bob, ChatVisibility::Archived)
|
||||
.set_visibility(&t, ChatVisibility::Archived)
|
||||
.await
|
||||
.ok();
|
||||
let chats = Chatlist::try_load(bob, DC_GCL_ARCHIVED_ONLY, None, None)
|
||||
let chats = Chatlist::try_load(&t, DC_GCL_ARCHIVED_ONLY, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(chats.len(), 1);
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::env;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{bail, ensure, Context as _, Result};
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use base64::Engine as _;
|
||||
use deltachat_contact_tools::{addr_cmp, sanitize_single_line};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -13,12 +13,10 @@ use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::blob::BlobObject;
|
||||
use crate::configure::EnteredLoginParam;
|
||||
use crate::constants;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::log::LogExt;
|
||||
use crate::login_param::ConfiguredLoginParam;
|
||||
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
|
||||
use crate::provider::{get_provider_by_id, Provider};
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
@@ -184,6 +182,12 @@ pub enum Config {
|
||||
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
|
||||
MediaQuality,
|
||||
|
||||
/// If set to "1", on the first time `start_io()` is called after configuring,
|
||||
/// the newest existing messages are fetched.
|
||||
/// Existing recipients are added to the contact database regardless of this setting.
|
||||
#[strum(props(default = "0"))]
|
||||
FetchExistingMsgs,
|
||||
|
||||
/// If set to "1", then existing messages are considered to be already fetched.
|
||||
/// This flag is reset after successful configuration.
|
||||
#[strum(props(default = "1"))]
|
||||
@@ -477,10 +481,7 @@ impl Config {
|
||||
|
||||
/// Whether the config option needs an IO scheduler restart to take effect.
|
||||
pub(crate) fn needs_io_restart(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Config::MvboxMove | Config::OnlyFetchMvbox | Config::SentboxWatch
|
||||
)
|
||||
matches!(self, Config::OnlyFetchMvbox | Config::SentboxWatch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,22 +528,21 @@ impl Context {
|
||||
// Default values
|
||||
let val = match key {
|
||||
Config::BccSelf => match Box::pin(self.is_chatmail()).await? {
|
||||
false => Some("1".to_string()),
|
||||
true => Some("0".to_string()),
|
||||
false => Some("1"),
|
||||
true => Some("0"),
|
||||
},
|
||||
Config::ConfiguredInboxFolder => Some("INBOX".to_string()),
|
||||
Config::ConfiguredInboxFolder => Some("INBOX"),
|
||||
Config::DeleteServerAfter => {
|
||||
match !Box::pin(self.get_config_bool(Config::BccSelf)).await?
|
||||
&& Box::pin(self.is_chatmail()).await?
|
||||
{
|
||||
true => Some("1".to_string()),
|
||||
false => Some("0".to_string()),
|
||||
true => Some("1"),
|
||||
false => Some("0"),
|
||||
}
|
||||
}
|
||||
Config::Addr => self.get_config_opt(Config::ConfiguredAddr).await?,
|
||||
_ => key.get_str("default").map(|s| s.to_string()),
|
||||
_ => key.get_str("default"),
|
||||
};
|
||||
Ok(val)
|
||||
Ok(val.map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
/// Returns Some(T) if a value for the given key is set and was successfully parsed.
|
||||
@@ -707,6 +707,7 @@ impl Context {
|
||||
| Config::SentboxWatch
|
||||
| Config::MvboxMove
|
||||
| Config::OnlyFetchMvbox
|
||||
| Config::FetchExistingMsgs
|
||||
| Config::DeleteToTrash
|
||||
| Config::Configured
|
||||
| Config::Bot
|
||||
@@ -808,19 +809,6 @@ impl Context {
|
||||
.set_raw_config(constants::DC_FOLDERS_CONFIGURED_KEY, None)
|
||||
.await?;
|
||||
}
|
||||
Config::ConfiguredAddr => {
|
||||
if self.is_configured().await? {
|
||||
bail!("Cannot change ConfiguredAddr");
|
||||
}
|
||||
if let Some(addr) = value {
|
||||
info!(self, "Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!");
|
||||
ConfiguredLoginParam::from_json(&format!(
|
||||
r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic","oauth2":false}}"#
|
||||
))?
|
||||
.save_to_transports_table(self, &EnteredLoginParam::default())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.sql.set_raw_config(key.as_ref(), value).await?;
|
||||
}
|
||||
@@ -907,7 +895,6 @@ impl Context {
|
||||
/// primary address (if exists) as a secondary address.
|
||||
///
|
||||
/// This should only be used by test code and during configure.
|
||||
#[cfg(test)] // AEAP is disabled, but there are still tests for it
|
||||
pub(crate) async fn set_primary_self_addr(&self, primary_new: &str) -> Result<()> {
|
||||
self.quota.write().await.take();
|
||||
|
||||
@@ -921,8 +908,7 @@ impl Context {
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.sql
|
||||
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(primary_new))
|
||||
self.set_config_internal(Config::ConfiguredAddr, Some(primary_new))
|
||||
.await?;
|
||||
self.emit_event(EventType::ConnectivityChanged);
|
||||
Ok(())
|
||||
|
||||
191
src/configure.rs
191
src/configure.rs
@@ -16,7 +16,7 @@ pub(crate) mod server_params;
|
||||
use anyhow::{bail, ensure, format_err, Context as _, Result};
|
||||
use auto_mozilla::moz_autoconfigure;
|
||||
use auto_outlook::outlk_autodiscover;
|
||||
use deltachat_contact_tools::{addr_normalize, EmailAddress};
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use futures::FutureExt;
|
||||
use futures_lite::FutureExt as _;
|
||||
use percent_encoding::utf8_percent_encode;
|
||||
@@ -28,14 +28,13 @@ use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
|
||||
use crate::context::Context;
|
||||
use crate::imap::Imap;
|
||||
use crate::log::LogExt;
|
||||
pub use crate::login_param::EnteredLoginParam;
|
||||
use crate::login_param::{
|
||||
ConfiguredCertificateChecks, ConfiguredLoginParam, ConfiguredServerLoginParam,
|
||||
ConnectionCandidate, EnteredCertificateChecks, ProxyConfig,
|
||||
ConnectionCandidate, EnteredCertificateChecks, EnteredLoginParam,
|
||||
};
|
||||
use crate::message::Message;
|
||||
use crate::oauth2::get_oauth2_addr;
|
||||
use crate::provider::{Protocol, Provider, Socket, UsernamePattern};
|
||||
use crate::provider::{Protocol, Socket, UsernamePattern};
|
||||
use crate::smtp::Smtp;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::time;
|
||||
@@ -62,62 +61,11 @@ macro_rules! progress {
|
||||
impl Context {
|
||||
/// Checks if the context is already configured.
|
||||
pub async fn is_configured(&self) -> Result<bool> {
|
||||
self.sql.exists("SELECT COUNT(*) FROM transports", ()).await
|
||||
self.sql.get_raw_config_bool("configured").await
|
||||
}
|
||||
|
||||
/// Configures this account with the currently provided parameters.
|
||||
///
|
||||
/// Deprecated since 2025-02; use `add_transport_from_qr()`
|
||||
/// or `add_or_update_transport()` instead.
|
||||
/// Configures this account with the currently set parameters.
|
||||
pub async fn configure(&self) -> Result<()> {
|
||||
let mut param = EnteredLoginParam::load(self).await?;
|
||||
|
||||
self.add_transport_inner(&mut param).await
|
||||
}
|
||||
|
||||
/// Configures a new email account using the provided parameters
|
||||
/// and adds it as a transport.
|
||||
///
|
||||
/// If the email address is the same as an existing transport,
|
||||
/// then this existing account will be reconfigured instead of a new one being added.
|
||||
///
|
||||
/// This function stops and starts IO as needed.
|
||||
///
|
||||
/// Usually it will be enough to only set `addr` and `imap.password`,
|
||||
/// and all the other settings will be autoconfigured.
|
||||
///
|
||||
/// During configuration, ConfigureProgress events are emitted;
|
||||
/// they indicate a successful configuration as well as errors
|
||||
/// and may be used to create a progress bar.
|
||||
/// This function will return after configuration is finished.
|
||||
///
|
||||
/// If configuration is successful,
|
||||
/// the working server parameters will be saved
|
||||
/// and used for connecting to the server.
|
||||
/// The parameters entered by the user will be saved separately
|
||||
/// so that they can be prefilled when the user opens the server-configuration screen again.
|
||||
///
|
||||
/// See also:
|
||||
/// - [Self::is_configured()] to check whether there is
|
||||
/// at least one working transport.
|
||||
/// - [Self::add_transport_from_qr()] to add a transport
|
||||
/// from a server encoded in a QR code.
|
||||
/// - [Self::list_transports()] to get a list of all configured transports.
|
||||
/// - [Self::delete_transport()] to remove a transport.
|
||||
pub async fn add_or_update_transport(&self, param: &mut EnteredLoginParam) -> Result<()> {
|
||||
self.stop_io().await;
|
||||
let result = self.add_transport_inner(param).await;
|
||||
if result.is_err() {
|
||||
if let Ok(true) = self.is_configured().await {
|
||||
self.start_io().await;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
self.start_io().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_transport_inner(&self, param: &mut EnteredLoginParam) -> Result<()> {
|
||||
ensure!(
|
||||
!self.scheduler.is_running().await,
|
||||
"cannot configure, already running"
|
||||
@@ -126,116 +74,55 @@ impl Context {
|
||||
self.sql.is_open().await,
|
||||
"cannot configure, database not opened."
|
||||
);
|
||||
param.addr = addr_normalize(¶m.addr);
|
||||
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
|
||||
if self.is_configured().await? && !addr_cmp(&old_addr.unwrap_or_default(), ¶m.addr) {
|
||||
let error_msg = "Changing your email address is not supported right now. Check back in a few months!";
|
||||
progress!(self, 0, Some(error_msg.to_string()));
|
||||
bail!(error_msg);
|
||||
}
|
||||
let cancel_channel = self.alloc_ongoing().await?;
|
||||
|
||||
let res = self
|
||||
.inner_configure(param)
|
||||
.inner_configure()
|
||||
.race(cancel_channel.recv().map(|_| Err(format_err!("Cancelled"))))
|
||||
.await;
|
||||
|
||||
self.free_ongoing().await;
|
||||
|
||||
if let Err(err) = res.as_ref() {
|
||||
// We are using Anyhow's .context() and to show the
|
||||
// inner error, too, we need the {:#}:
|
||||
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}")).await;
|
||||
progress!(self, 0, Some(error_msg.clone()));
|
||||
bail!(error_msg);
|
||||
progress!(
|
||||
self,
|
||||
0,
|
||||
Some(
|
||||
stock_str::configuration_failed(
|
||||
self,
|
||||
// We are using Anyhow's .context() and to show the
|
||||
// inner error, too, we need the {:#}:
|
||||
&format!("{err:#}"),
|
||||
)
|
||||
.await
|
||||
)
|
||||
);
|
||||
} else {
|
||||
param.save(self).await?;
|
||||
progress!(self, 1000);
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
/// Adds a new email account as a transport
|
||||
/// using the server encoded in the QR code.
|
||||
/// See [Self::add_or_update_transport].
|
||||
pub async fn add_transport_from_qr(&self, qr: &str) -> Result<()> {
|
||||
self.stop_io().await;
|
||||
|
||||
// This code first sets the deprecated Config::Addr, Config::MailPw, etc.
|
||||
// and then calls configure(), which loads them again.
|
||||
// At some point, we will remove configure()
|
||||
// and then simplify the code
|
||||
// to directly create an EnteredLoginParam.
|
||||
let result = async move {
|
||||
match crate::qr::check_qr(self, qr).await? {
|
||||
crate::qr::Qr::Account { .. } => crate::qr::set_account_from_qr(self, qr).await?,
|
||||
crate::qr::Qr::Login { address, options } => {
|
||||
crate::qr::configure_from_login_qr(self, &address, options).await?
|
||||
}
|
||||
_ => bail!("QR code does not contain account"),
|
||||
}
|
||||
self.configure().await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
if let Ok(true) = self.is_configured().await {
|
||||
self.start_io().await;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
self.start_io().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the list of all email accounts that are used as a transport in the current profile.
|
||||
/// Use [Self::add_or_update_transport()] to add or change a transport
|
||||
/// and [Self::delete_transport()] to delete a transport.
|
||||
pub async fn list_transports(&self) -> Result<Vec<EnteredLoginParam>> {
|
||||
let transports = self
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT entered_param FROM transports",
|
||||
(),
|
||||
|row| row.get::<_, String>(0),
|
||||
|rows| {
|
||||
rows.flatten()
|
||||
.map(|s| Ok(serde_json::from_str(&s)?))
|
||||
.collect::<Result<Vec<EnteredLoginParam>>>()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(transports)
|
||||
}
|
||||
|
||||
/// Removes the transport with the specified email address
|
||||
/// (i.e. [EnteredLoginParam::addr]).
|
||||
#[expect(clippy::unused_async)]
|
||||
pub async fn delete_transport(&self, _addr: &str) -> Result<()> {
|
||||
bail!("Adding and removing additional transports is not supported yet. Check back in a few months!")
|
||||
}
|
||||
|
||||
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
|
||||
async fn inner_configure(&self) -> Result<()> {
|
||||
info!(self, "Configure ...");
|
||||
|
||||
let param = EnteredLoginParam::load(self).await?;
|
||||
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
|
||||
let provider = configure(self, param).await?;
|
||||
let configured_param = configure(self, ¶m).await?;
|
||||
self.set_config_internal(Config::NotifyAboutWrongPw, Some("1"))
|
||||
.await?;
|
||||
on_configure_completed(self, provider, old_addr).await?;
|
||||
on_configure_completed(self, configured_param, old_addr).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_configure_completed(
|
||||
context: &Context,
|
||||
provider: Option<&'static Provider>,
|
||||
param: ConfiguredLoginParam,
|
||||
old_addr: Option<String>,
|
||||
) -> Result<()> {
|
||||
if let Some(provider) = provider {
|
||||
if let Some(provider) = param.provider {
|
||||
if let Some(config_defaults) = provider.config_defaults {
|
||||
for def in config_defaults {
|
||||
if !context.config_exists(def.key).await? {
|
||||
@@ -298,7 +185,8 @@ async fn get_configured_param(
|
||||
param.smtp.password.clone()
|
||||
};
|
||||
|
||||
let proxy_enabled = ctx.get_config_bool(Config::ProxyEnabled).await?;
|
||||
let proxy_config = param.proxy_config.clone();
|
||||
let proxy_enabled = proxy_config.is_some();
|
||||
|
||||
let mut addr = param.addr.clone();
|
||||
if param.oauth2 {
|
||||
@@ -457,6 +345,7 @@ async fn get_configured_param(
|
||||
.collect(),
|
||||
smtp_user: param.smtp.user.clone(),
|
||||
smtp_password,
|
||||
proxy_config: param.proxy_config.clone(),
|
||||
provider,
|
||||
certificate_checks: match param.certificate_checks {
|
||||
EnteredCertificateChecks::Automatic => ConfiguredCertificateChecks::Automatic,
|
||||
@@ -471,15 +360,14 @@ async fn get_configured_param(
|
||||
Ok(configured_login_param)
|
||||
}
|
||||
|
||||
async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'static Provider>> {
|
||||
async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<ConfiguredLoginParam> {
|
||||
progress!(ctx, 1);
|
||||
|
||||
let ctx2 = ctx.clone();
|
||||
let update_device_chats_handle = task::spawn(async move { ctx2.update_device_chats().await });
|
||||
|
||||
let configured_param = get_configured_param(ctx, param).await?;
|
||||
let proxy_config = ProxyConfig::load(ctx).await?;
|
||||
let strict_tls = configured_param.strict_tls(proxy_config.is_some());
|
||||
let strict_tls = configured_param.strict_tls();
|
||||
|
||||
progress!(ctx, 550);
|
||||
|
||||
@@ -489,15 +377,15 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
let smtp_param = configured_param.smtp.clone();
|
||||
let smtp_password = configured_param.smtp_password.clone();
|
||||
let smtp_addr = configured_param.addr.clone();
|
||||
let proxy_config = configured_param.proxy_config.clone();
|
||||
|
||||
let proxy_config2 = proxy_config.clone();
|
||||
let smtp_config_task = task::spawn(async move {
|
||||
let mut smtp = Smtp::new();
|
||||
smtp.connect(
|
||||
&context_smtp,
|
||||
&smtp_param,
|
||||
&smtp_password,
|
||||
&proxy_config2,
|
||||
&proxy_config,
|
||||
&smtp_addr,
|
||||
strict_tls,
|
||||
configured_param.oauth2,
|
||||
@@ -515,7 +403,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
let mut imap = Imap::new(
|
||||
configured_param.imap.clone(),
|
||||
configured_param.imap_password.clone(),
|
||||
proxy_config,
|
||||
configured_param.proxy_config.clone(),
|
||||
&configured_param.addr,
|
||||
strict_tls,
|
||||
configured_param.oauth2,
|
||||
@@ -524,10 +412,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
let configuring = true;
|
||||
let mut imap_session = match imap.connect(ctx, configuring).await {
|
||||
Ok(session) => session,
|
||||
Err(err) => bail!(
|
||||
"{}",
|
||||
nicer_configuration_error(ctx, format!("{err:#}")).await
|
||||
),
|
||||
Err(err) => bail!("{}", nicer_configuration_error(ctx, err.to_string()).await),
|
||||
};
|
||||
|
||||
progress!(ctx, 850);
|
||||
@@ -581,11 +466,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
}
|
||||
}
|
||||
|
||||
let provider = configured_param.provider;
|
||||
configured_param
|
||||
.save_to_transports_table(ctx, param)
|
||||
.await?;
|
||||
|
||||
configured_param.save_as_configured_params(ctx).await?;
|
||||
ctx.set_config_internal(Config::ConfiguredTimestamp, Some(&time().to_string()))
|
||||
.await?;
|
||||
|
||||
@@ -601,7 +482,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
|
||||
ctx.sql.set_raw_config_bool("configured", true).await?;
|
||||
ctx.emit_event(EventType::AccountsItemChanged);
|
||||
|
||||
Ok(provider)
|
||||
Ok(configured_param)
|
||||
}
|
||||
|
||||
/// Retrieve available autoconfigurations.
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use deltachat_derive::{FromSql, ToSql};
|
||||
use once_cell::sync::Lazy;
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::chat::ChatId;
|
||||
|
||||
pub static DC_VERSION_STR: LazyLock<String> =
|
||||
LazyLock::new(|| env!("CARGO_PKG_VERSION").to_string());
|
||||
pub static DC_VERSION_STR: Lazy<String> = Lazy::new(|| env!("CARGO_PKG_VERSION").to_string());
|
||||
|
||||
/// Set of characters to percent-encode in email addresses and names.
|
||||
pub(crate) const NON_ALPHANUMERIC_WITHOUT_DOT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'.');
|
||||
@@ -180,6 +178,9 @@ pub const DC_LP_AUTH_NORMAL: i32 = 0x4;
|
||||
/// if none of these flags are set, the default is chosen
|
||||
pub const DC_LP_AUTH_FLAGS: i32 = DC_LP_AUTH_OAUTH2 | DC_LP_AUTH_NORMAL;
|
||||
|
||||
/// How many existing messages shall be fetched after configuration.
|
||||
pub(crate) const DC_FETCH_EXISTING_MSGS_COUNT: i64 = 100;
|
||||
|
||||
// max. weight of images to send w/o recoding
|
||||
pub const BALANCED_IMAGE_BYTES: usize = 500_000;
|
||||
pub const WORSE_IMAGE_BYTES: usize = 130_000;
|
||||
@@ -220,19 +221,6 @@ pub(crate) const SECUREJOIN_WAIT_TIMEOUT: u64 = 15;
|
||||
// Newer Delta Chats will remove the prefix as needed.
|
||||
pub(crate) const EDITED_PREFIX: &str = "✏️";
|
||||
|
||||
// Strings needed to render the Autocrypt Setup Message.
|
||||
// Left untranslated as not being supported/recommended workflow and as translations would require deep knowledge.
|
||||
pub(crate) const ASM_SUBJECT: &str = "Autocrypt Setup Message";
|
||||
pub(crate) const ASM_BODY: &str = "This is the Autocrypt Setup Message \
|
||||
used to transfer your end-to-end setup between clients.
|
||||
|
||||
To decrypt and use your setup, \
|
||||
open the message in an Autocrypt-compliant client \
|
||||
and enter the setup code presented on the generating device.
|
||||
|
||||
If you see this message in a chatmail client (Delta Chat, Arcane Chat, Delta Touch ...), \
|
||||
use \"Settings / Add Second Device\" instead.";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use num_traits::FromPrimitive;
|
||||
|
||||
293
src/contact.rs
293
src/contact.rs
@@ -95,50 +95,6 @@ impl ContactId {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Sets display name for existing contact.
|
||||
///
|
||||
/// Display name may be an empty string,
|
||||
/// in which case the name displayed in the UI
|
||||
/// for this contact will switch to the
|
||||
/// contact's authorized name.
|
||||
pub async fn set_name(self, context: &Context, name: &str) -> Result<()> {
|
||||
let addr = context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let is_changed = transaction.execute(
|
||||
"UPDATE contacts SET name=?1 WHERE id=?2 AND name!=?1",
|
||||
(name, self),
|
||||
)? > 0;
|
||||
if is_changed {
|
||||
update_chat_names(context, transaction, self)?;
|
||||
let addr = transaction.query_row(
|
||||
"SELECT addr FROM contacts WHERE id=?",
|
||||
(self,),
|
||||
|row| {
|
||||
let addr: String = row.get(0)?;
|
||||
Ok(addr)
|
||||
},
|
||||
)?;
|
||||
Ok(Some(addr))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Some(addr) = addr {
|
||||
chat::sync(
|
||||
context,
|
||||
chat::SyncId::ContactAddr(addr.to_string()),
|
||||
chat::SyncAction::Rename(name.to_string()),
|
||||
)
|
||||
.await
|
||||
.log_err(context)
|
||||
.ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark contact as bot.
|
||||
pub(crate) async fn mark_bot(&self, context: &Context, is_bot: bool) -> Result<()> {
|
||||
context
|
||||
@@ -887,48 +843,44 @@ impl Contact {
|
||||
|
||||
let mut update_addr = false;
|
||||
|
||||
let row_id = context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
let row = transaction
|
||||
.query_row(
|
||||
"SELECT id, name, addr, origin, authname
|
||||
let row_id = context.sql.transaction(|transaction| {
|
||||
let row = transaction.query_row(
|
||||
"SELECT id, name, addr, origin, authname
|
||||
FROM contacts WHERE addr=? COLLATE NOCASE",
|
||||
(addr,),
|
||||
|row| {
|
||||
let row_id: u32 = row.get(0)?;
|
||||
let row_name: String = row.get(1)?;
|
||||
let row_addr: String = row.get(2)?;
|
||||
let row_origin: Origin = row.get(3)?;
|
||||
let row_authname: String = row.get(4)?;
|
||||
[addr.to_string()],
|
||||
|row| {
|
||||
let row_id: isize = row.get(0)?;
|
||||
let row_name: String = row.get(1)?;
|
||||
let row_addr: String = row.get(2)?;
|
||||
let row_origin: Origin = row.get(3)?;
|
||||
let row_authname: String = row.get(4)?;
|
||||
|
||||
Ok((row_id, row_name, row_addr, row_origin, row_authname))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
Ok((row_id, row_name, row_addr, row_origin, row_authname))
|
||||
}).optional()?;
|
||||
|
||||
let row_id;
|
||||
if let Some((id, row_name, row_addr, row_origin, row_authname)) = row {
|
||||
let update_name = manual && name != row_name;
|
||||
let update_authname = !manual
|
||||
&& name != row_authname
|
||||
&& !name.is_empty()
|
||||
&& (origin >= row_origin
|
||||
|| origin == Origin::IncomingUnknownFrom
|
||||
|| row_authname.is_empty());
|
||||
let row_id;
|
||||
if let Some((id, row_name, row_addr, row_origin, row_authname)) = row {
|
||||
let update_name = manual && name != row_name;
|
||||
let update_authname = !manual
|
||||
&& name != row_authname
|
||||
&& !name.is_empty()
|
||||
&& (origin >= row_origin
|
||||
|| origin == Origin::IncomingUnknownFrom
|
||||
|| row_authname.is_empty());
|
||||
|
||||
row_id = id;
|
||||
if origin >= row_origin && addr.as_ref() != row_addr {
|
||||
update_addr = true;
|
||||
}
|
||||
if update_name || update_authname || update_addr || origin > row_origin {
|
||||
let new_name = if update_name {
|
||||
name.to_string()
|
||||
} else {
|
||||
row_name
|
||||
};
|
||||
row_id = u32::try_from(id)?;
|
||||
if origin >= row_origin && addr.as_ref() != row_addr {
|
||||
update_addr = true;
|
||||
}
|
||||
if update_name || update_authname || update_addr || origin > row_origin {
|
||||
let new_name = if update_name {
|
||||
name.to_string()
|
||||
} else {
|
||||
row_name
|
||||
};
|
||||
|
||||
transaction.execute(
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE contacts SET name=?, addr=?, origin=?, authname=? WHERE id=?;",
|
||||
(
|
||||
new_name,
|
||||
@@ -947,38 +899,88 @@ impl Contact {
|
||||
} else {
|
||||
row_authname
|
||||
},
|
||||
row_id,
|
||||
row_id
|
||||
),
|
||||
)?;
|
||||
|
||||
if update_name || update_authname {
|
||||
let contact_id = ContactId::new(row_id);
|
||||
update_chat_names(context, transaction, contact_id)?;
|
||||
}
|
||||
sth_modified = Modifier::Modified;
|
||||
}
|
||||
} else {
|
||||
let update_name = manual;
|
||||
let update_authname = !manual;
|
||||
if update_name || update_authname {
|
||||
// Update the contact name also if it is used as a group name.
|
||||
// This is one of the few duplicated data, however, getting the chat list is easier this way.
|
||||
let chat_id: Option<ChatId> = transaction.query_row(
|
||||
"SELECT id FROM chats WHERE type=? AND id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?)",
|
||||
(Chattype::Single, isize::try_from(row_id)?),
|
||||
|row| {
|
||||
let chat_id: ChatId = row.get(0)?;
|
||||
Ok(chat_id)
|
||||
}
|
||||
).optional()?;
|
||||
|
||||
transaction.execute(
|
||||
if let Some(chat_id) = chat_id {
|
||||
let contact_id = ContactId::new(row_id);
|
||||
let (addr, name, authname) =
|
||||
transaction.query_row(
|
||||
"SELECT addr, name, authname
|
||||
FROM contacts
|
||||
WHERE id=?",
|
||||
(contact_id,),
|
||||
|row| {
|
||||
let addr: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let authname: String = row.get(2)?;
|
||||
Ok((addr, name, authname))
|
||||
})?;
|
||||
|
||||
let chat_name = if !name.is_empty() {
|
||||
name
|
||||
} else if !authname.is_empty() {
|
||||
authname
|
||||
} else {
|
||||
addr
|
||||
};
|
||||
|
||||
let count = transaction.execute(
|
||||
"UPDATE chats SET name=?1 WHERE id=?2 AND name!=?1",
|
||||
(chat_name, chat_id))?;
|
||||
|
||||
if count > 0 {
|
||||
// Chat name updated
|
||||
context.emit_event(EventType::ChatModified(chat_id));
|
||||
chatlist_events::emit_chatlist_items_changed_for_contact(context, contact_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
sth_modified = Modifier::Modified;
|
||||
}
|
||||
} else {
|
||||
let update_name = manual;
|
||||
let update_authname = !manual;
|
||||
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO contacts (name, addr, origin, authname)
|
||||
VALUES (?, ?, ?, ?);",
|
||||
(
|
||||
if update_name { &name } else { "" },
|
||||
(
|
||||
if update_name {
|
||||
name.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
&addr,
|
||||
origin,
|
||||
if update_authname { &name } else { "" },
|
||||
if update_authname {
|
||||
name.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
),
|
||||
)?;
|
||||
|
||||
sth_modified = Modifier::Created;
|
||||
row_id = u32::try_from(transaction.last_insert_rowid())?;
|
||||
info!(context, "Added contact id={row_id} addr={addr}.");
|
||||
}
|
||||
Ok(row_id)
|
||||
})
|
||||
.await?;
|
||||
sth_modified = Modifier::Created;
|
||||
row_id = u32::try_from(transaction.last_insert_rowid())?;
|
||||
info!(context, "Added contact id={row_id} addr={addr}.");
|
||||
}
|
||||
Ok(row_id)
|
||||
}).await?;
|
||||
|
||||
let contact_id = ContactId::new(row_id);
|
||||
|
||||
@@ -1277,16 +1279,9 @@ impl Contact {
|
||||
.map(|k| k.dc_fingerprint().to_string())
|
||||
.unwrap_or_default();
|
||||
if addr < peerstate.addr {
|
||||
cat_fingerprint(&mut ret, &addr, &fingerprint_self, "");
|
||||
cat_fingerprint(
|
||||
&mut ret,
|
||||
&stock_str::self_msg(context).await,
|
||||
&addr,
|
||||
&fingerprint_self,
|
||||
"",
|
||||
);
|
||||
cat_fingerprint(
|
||||
&mut ret,
|
||||
contact.get_display_name(),
|
||||
&peerstate.addr,
|
||||
&fingerprint_other_verified,
|
||||
&fingerprint_other_unverified,
|
||||
@@ -1294,18 +1289,11 @@ impl Contact {
|
||||
} else {
|
||||
cat_fingerprint(
|
||||
&mut ret,
|
||||
contact.get_display_name(),
|
||||
&peerstate.addr,
|
||||
&fingerprint_other_verified,
|
||||
&fingerprint_other_unverified,
|
||||
);
|
||||
cat_fingerprint(
|
||||
&mut ret,
|
||||
&stock_str::self_msg(context).await,
|
||||
&addr,
|
||||
&fingerprint_self,
|
||||
"",
|
||||
);
|
||||
cat_fingerprint(&mut ret, &addr, &fingerprint_self, "");
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
@@ -1406,13 +1394,16 @@ impl Contact {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Get authorized name or address.
|
||||
/// Get a summary of authorized name and address.
|
||||
///
|
||||
/// The returned string is either "Name (email@domain.com)" or just
|
||||
/// "email@domain.com" if the name is unset.
|
||||
///
|
||||
/// This string is suitable for sending over email
|
||||
/// as it does not leak the locally set name.
|
||||
pub(crate) fn get_authname_or_addr(&self) -> String {
|
||||
pub fn get_authname_n_addr(&self) -> String {
|
||||
if !self.authname.is_empty() {
|
||||
(&self.authname).into()
|
||||
format!("{} ({})", self.authname, self.addr)
|
||||
} else {
|
||||
(&self.addr).into()
|
||||
}
|
||||
@@ -1615,60 +1606,6 @@ impl Contact {
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the names of the chats which use the contact name.
|
||||
//
|
||||
// This is one of the few duplicated data, however, getting the chat list is easier this way.
|
||||
fn update_chat_names(
|
||||
context: &Context,
|
||||
transaction: &rusqlite::Connection,
|
||||
contact_id: ContactId,
|
||||
) -> Result<()> {
|
||||
let chat_id: Option<ChatId> = transaction.query_row(
|
||||
"SELECT id FROM chats WHERE type=? AND id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?)",
|
||||
(Chattype::Single, contact_id),
|
||||
|row| {
|
||||
let chat_id: ChatId = row.get(0)?;
|
||||
Ok(chat_id)
|
||||
}
|
||||
).optional()?;
|
||||
|
||||
if let Some(chat_id) = chat_id {
|
||||
let (addr, name, authname) = transaction.query_row(
|
||||
"SELECT addr, name, authname
|
||||
FROM contacts
|
||||
WHERE id=?",
|
||||
(contact_id,),
|
||||
|row| {
|
||||
let addr: String = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let authname: String = row.get(2)?;
|
||||
Ok((addr, name, authname))
|
||||
},
|
||||
)?;
|
||||
|
||||
let chat_name = if !name.is_empty() {
|
||||
name
|
||||
} else if !authname.is_empty() {
|
||||
authname
|
||||
} else {
|
||||
addr
|
||||
};
|
||||
|
||||
let count = transaction.execute(
|
||||
"UPDATE chats SET name=?1 WHERE id=?2 AND name!=?1",
|
||||
(chat_name, chat_id),
|
||||
)?;
|
||||
|
||||
if count > 0 {
|
||||
// Chat name updated
|
||||
context.emit_event(EventType::ChatModified(chat_id));
|
||||
chatlist_events::emit_chatlist_items_changed_for_contact(context, contact_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_blocked(
|
||||
context: &Context,
|
||||
sync: sync::Sync,
|
||||
@@ -1859,14 +1796,12 @@ pub(crate) async fn update_last_seen(
|
||||
|
||||
fn cat_fingerprint(
|
||||
ret: &mut String,
|
||||
name: &str,
|
||||
addr: &str,
|
||||
fingerprint_verified: &str,
|
||||
fingerprint_unverified: &str,
|
||||
) {
|
||||
*ret += &format!(
|
||||
"\n\n{} ({}):\n{}",
|
||||
name,
|
||||
"\n\n{}:\n{}",
|
||||
addr,
|
||||
if !fingerprint_verified.is_empty() {
|
||||
fingerprint_verified
|
||||
@@ -1878,7 +1813,7 @@ fn cat_fingerprint(
|
||||
&& !fingerprint_unverified.is_empty()
|
||||
&& fingerprint_verified != fingerprint_unverified
|
||||
{
|
||||
*ret += &format!("\n\n{name} (alternative):\n{fingerprint_unverified}");
|
||||
*ret += &format!("\n\n{addr} (alternative):\n{fingerprint_unverified}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -763,11 +763,11 @@ async fn test_contact_get_encrinfo() -> Result<()> {
|
||||
"End-to-end encryption preferred.
|
||||
Fingerprints:
|
||||
|
||||
Me (alice@example.org):
|
||||
alice@example.org:
|
||||
2E6F A2CB 23B5 32D7 2863
|
||||
4B58 64B0 8F61 A9ED 9443
|
||||
|
||||
Bob (bob@example.net):
|
||||
bob@example.net:
|
||||
CCCB 5AA9 F6E1 141C 9431
|
||||
65F1 DB18 B18C BCF7 0487"
|
||||
);
|
||||
@@ -1050,9 +1050,8 @@ async fn test_sync_create() -> Result<()> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_make_n_import_vcard() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice = &TestContext::new_alice().await;
|
||||
let bob = &TestContext::new_bob().await;
|
||||
bob.set_config(Config::Displayname, Some("Bob")).await?;
|
||||
let avatar_path = bob.dir.path().join("avatar.png");
|
||||
let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png");
|
||||
|
||||
@@ -903,6 +903,12 @@ impl Context {
|
||||
}
|
||||
|
||||
res.insert("secondary_addrs", secondary_addrs);
|
||||
res.insert(
|
||||
"fetch_existing_msgs",
|
||||
self.get_config_int(Config::FetchExistingMsgs)
|
||||
.await?
|
||||
.to_string(),
|
||||
);
|
||||
res.insert(
|
||||
"fetched_existing_msgs",
|
||||
self.get_config_bool(Config::FetchedExistingMsgs)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! A module to remove HTML tags from the email text
|
||||
|
||||
use std::io::BufRead;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use quick_xml::{
|
||||
events::{BytesEnd, BytesStart, BytesText},
|
||||
Reader,
|
||||
@@ -176,8 +176,7 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
|
||||
}
|
||||
|
||||
fn dehtml_text_cb(event: &BytesText, dehtml: &mut Dehtml) {
|
||||
static LINE_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"(\r?\n)+").unwrap());
|
||||
static LINE_RE: Lazy<regex::Regex> = Lazy::new(|| regex::Regex::new(r"(\r?\n)+").unwrap());
|
||||
|
||||
if dehtml.get_add_text() == AddText::YesPreserveLineEnds
|
||||
|| dehtml.get_add_text() == AddText::YesRemoveLineEnds
|
||||
|
||||
@@ -220,6 +220,7 @@ impl Session {
|
||||
vec![uid],
|
||||
&uid_message_ids,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
if last_uid.is_none() {
|
||||
@@ -368,6 +369,7 @@ mod tests {
|
||||
header.as_bytes(),
|
||||
false,
|
||||
Some(100000),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let msg = t.get_last_msg().await;
|
||||
@@ -383,6 +385,7 @@ mod tests {
|
||||
format!("{header}\n\n100k text...").as_bytes(),
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let msg = t.get_last_msg().await;
|
||||
@@ -417,6 +420,7 @@ mod tests {
|
||||
Content-Type: text/plain",
|
||||
false,
|
||||
Some(100000),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
@@ -453,6 +457,7 @@ mod tests {
|
||||
sent2.payload().as_bytes(),
|
||||
false,
|
||||
Some(sent2.payload().len() as u32),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let msg = bob.get_last_msg().await;
|
||||
@@ -468,6 +473,7 @@ mod tests {
|
||||
sent2.payload().as_bytes(),
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 0);
|
||||
@@ -511,7 +517,15 @@ mod tests {
|
||||
";
|
||||
|
||||
// not downloading the mdn results in an placeholder
|
||||
receive_imf_from_inbox(&bob, "bar@example.org", raw, false, Some(raw.len() as u32)).await?;
|
||||
receive_imf_from_inbox(
|
||||
&bob,
|
||||
"bar@example.org",
|
||||
raw,
|
||||
false,
|
||||
Some(raw.len() as u32),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let msg = bob.get_last_msg().await;
|
||||
let chat_id = msg.chat_id;
|
||||
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 1);
|
||||
@@ -519,7 +533,7 @@ mod tests {
|
||||
|
||||
// downloading the mdn afterwards expands to nothing and deletes the placeholder directly
|
||||
// (usually mdn are too small for not being downloaded directly)
|
||||
receive_imf_from_inbox(&bob, "bar@example.org", raw, false, None).await?;
|
||||
receive_imf_from_inbox(&bob, "bar@example.org", raw, false, None, false).await?;
|
||||
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 0);
|
||||
assert!(Message::load_from_db_optional(&bob, msg.id)
|
||||
.await?
|
||||
|
||||
126
src/e2ee.rs
126
src/e2ee.rs
@@ -1,9 +1,8 @@
|
||||
//! End-to-end encryption support.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{format_err, Context as _, Result};
|
||||
use mail_builder::mime::MimePart;
|
||||
use num_traits::FromPrimitive;
|
||||
|
||||
@@ -43,76 +42,79 @@ impl EncryptHelper {
|
||||
}
|
||||
|
||||
/// Determines if we can and should encrypt.
|
||||
///
|
||||
/// `e2ee_guaranteed` should be set to true for replies to encrypted messages (as required by
|
||||
/// Autocrypt Level 1, version 1.1) and for messages sent in protected groups.
|
||||
///
|
||||
/// Returns an error if `e2ee_guaranteed` is true, but one or more keys are missing.
|
||||
pub(crate) async fn should_encrypt(
|
||||
&self,
|
||||
context: &Context,
|
||||
e2ee_guaranteed: bool,
|
||||
peerstates: &[(Option<Peerstate>, String)],
|
||||
) -> Result<bool> {
|
||||
let is_chatmail = context.is_chatmail().await?;
|
||||
for (peerstate, _addr) in peerstates {
|
||||
if let Some(peerstate) = peerstate {
|
||||
// For chatmail we ignore the encryption preference,
|
||||
// because we can either send encrypted or not at all.
|
||||
if is_chatmail || peerstate.prefer_encrypt != EncryptPreference::Reset {
|
||||
continue;
|
||||
let mut prefer_encrypt_count = 1;
|
||||
for (peerstate, addr) in peerstates {
|
||||
match peerstate {
|
||||
Some(peerstate) => {
|
||||
if match peerstate.prefer_encrypt {
|
||||
EncryptPreference::Reset => is_chatmail,
|
||||
EncryptPreference::NoPreference | EncryptPreference::Mutual => true,
|
||||
} {
|
||||
prefer_encrypt_count += 1;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let msg = format!("Peerstate for {addr:?} missing, cannot encrypt");
|
||||
if e2ee_guaranteed {
|
||||
return Err(format_err!("{msg}"));
|
||||
} else {
|
||||
info!(context, "{msg}.");
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
|
||||
// Count number of recipients, including self.
|
||||
// This does not depend on whether we send a copy to self or not.
|
||||
let recipients_count = peerstates.len() + 1;
|
||||
|
||||
Ok(e2ee_guaranteed || 2 * prefer_encrypt_count > recipients_count)
|
||||
}
|
||||
|
||||
/// Constructs a vector of public keys for given peerstates.
|
||||
///
|
||||
/// In addition returns the set of recipient addresses
|
||||
/// for which there is no key available.
|
||||
///
|
||||
/// Returns an error if there are recipients
|
||||
/// other than self, but no recipient keys are available.
|
||||
pub(crate) fn encryption_keyring(
|
||||
&self,
|
||||
/// Tries to encrypt the passed in `mail`.
|
||||
pub async fn encrypt(
|
||||
self,
|
||||
context: &Context,
|
||||
verified: bool,
|
||||
peerstates: &[(Option<Peerstate>, String)],
|
||||
) -> Result<(Vec<SignedPublicKey>, BTreeSet<String>)> {
|
||||
// Encrypt to self unconditionally,
|
||||
// even for a single-device setup.
|
||||
let mut keyring = vec![self.public_key.clone()];
|
||||
let mut missing_key_addresses = BTreeSet::new();
|
||||
|
||||
if peerstates.is_empty() {
|
||||
return Ok((keyring, missing_key_addresses));
|
||||
}
|
||||
mail_to_encrypt: MimePart<'static>,
|
||||
peerstates: Vec<(Option<Peerstate>, String)>,
|
||||
compress: bool,
|
||||
) -> Result<String> {
|
||||
let mut keyring: Vec<SignedPublicKey> = Vec::new();
|
||||
|
||||
let mut verifier_addresses: Vec<&str> = Vec::new();
|
||||
|
||||
for (peerstate, addr) in peerstates {
|
||||
if let Some(peerstate) = peerstate {
|
||||
if let Some(key) = peerstate.clone().take_key(verified) {
|
||||
keyring.push(key);
|
||||
verifier_addresses.push(addr);
|
||||
} else {
|
||||
warn!(context, "Encryption key for {addr} is missing.");
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
}
|
||||
} else {
|
||||
warn!(context, "Peerstate for {addr} is missing.");
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
}
|
||||
for (peerstate, addr) in peerstates
|
||||
.iter()
|
||||
.filter_map(|(state, addr)| state.clone().map(|s| (s, addr)))
|
||||
{
|
||||
let key = peerstate
|
||||
.take_key(verified)
|
||||
.with_context(|| format!("proper enc-key for {addr} missing, cannot encrypt"))?;
|
||||
keyring.push(key);
|
||||
verifier_addresses.push(addr);
|
||||
}
|
||||
|
||||
debug_assert!(
|
||||
!keyring.is_empty(),
|
||||
"At least our own key is in the keyring"
|
||||
);
|
||||
if keyring.len() <= 1 {
|
||||
bail!("No recipient keys are available, cannot encrypt");
|
||||
}
|
||||
// Encrypt to self.
|
||||
keyring.push(self.public_key.clone());
|
||||
|
||||
// Encrypt to secondary verified keys
|
||||
// if we also encrypt to the introducer ("verifier") of the key.
|
||||
if verified {
|
||||
for (peerstate, _addr) in peerstates {
|
||||
for (peerstate, _addr) in &peerstates {
|
||||
if let Some(peerstate) = peerstate {
|
||||
if let (Some(key), Some(verifier)) = (
|
||||
peerstate.secondary_verified_key.as_ref(),
|
||||
@@ -126,17 +128,6 @@ impl EncryptHelper {
|
||||
}
|
||||
}
|
||||
|
||||
Ok((keyring, missing_key_addresses))
|
||||
}
|
||||
|
||||
/// Tries to encrypt the passed in `mail`.
|
||||
pub async fn encrypt(
|
||||
self,
|
||||
context: &Context,
|
||||
keyring: Vec<SignedPublicKey>,
|
||||
mail_to_encrypt: MimePart<'static>,
|
||||
compress: bool,
|
||||
) -> Result<String> {
|
||||
let sign_key = load_self_secret_key(context).await?;
|
||||
|
||||
let mut raw_message = Vec::new();
|
||||
@@ -333,17 +324,22 @@ Sent with my Delta Chat Messenger: https://delta.chat";
|
||||
let encrypt_helper = EncryptHelper::new(&t).await.unwrap();
|
||||
|
||||
let ps = new_peerstates(EncryptPreference::NoPreference);
|
||||
assert!(encrypt_helper.should_encrypt(&t, &ps).await?);
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
|
||||
// Own preference is `Mutual` and we have the peer's key.
|
||||
assert!(encrypt_helper.should_encrypt(&t, false, &ps).await?);
|
||||
|
||||
let ps = new_peerstates(EncryptPreference::Reset);
|
||||
assert!(!encrypt_helper.should_encrypt(&t, &ps).await?);
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
|
||||
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).await?);
|
||||
|
||||
let ps = new_peerstates(EncryptPreference::Mutual);
|
||||
assert!(encrypt_helper.should_encrypt(&t, &ps).await?);
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
|
||||
assert!(encrypt_helper.should_encrypt(&t, false, &ps).await?);
|
||||
|
||||
// test with missing peerstate
|
||||
let ps = vec![(None, "bob@foo.bar".to_string())];
|
||||
assert!(!encrypt_helper.should_encrypt(&t, &ps).await?);
|
||||
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await.is_err());
|
||||
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::chat::{
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::constants::DC_CHAT_ID_ARCHIVED_LINK;
|
||||
use crate::contact::Contact;
|
||||
use crate::download::DownloadState;
|
||||
use crate::location;
|
||||
use crate::message::markseen_msgs;
|
||||
@@ -790,7 +791,7 @@ async fn test_ephemeral_timer_non_member() -> Result<()> {
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
let alice_bob_contact_id = Contact::create(alice, "Bob", "bob@example.net").await?;
|
||||
let alice_chat_id =
|
||||
create_group_chat(alice, ProtectionStatus::Unprotected, "Group name").await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
|
||||
43
src/html.rs
43
src/html.rs
@@ -285,7 +285,7 @@ mod tests {
|
||||
use crate::contact::ContactId;
|
||||
use crate::message::{MessengerMessage, Viewtype};
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
use crate::test_utils::TestContext;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_htmlparse_plain_unspecified() {
|
||||
@@ -442,25 +442,24 @@ test some special html-characters as < > and & but also " and &#x
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_html_forwarding() {
|
||||
// alice receives a non-delta html-message
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let alice = TestContext::new_alice().await;
|
||||
let chat = alice
|
||||
.create_chat_with_contact("", "sender@testrun.org")
|
||||
.await;
|
||||
let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
|
||||
receive_imf(alice, raw, false).await.unwrap();
|
||||
receive_imf(&alice, raw, false).await.unwrap();
|
||||
let msg = alice.get_last_msg_in(chat.get_id()).await;
|
||||
assert_ne!(msg.get_from_id(), ContactId::SELF);
|
||||
assert_eq!(msg.is_dc_message, MessengerMessage::No);
|
||||
assert!(!msg.is_forwarded());
|
||||
assert!(msg.get_text().contains("this is plain"));
|
||||
assert!(msg.has_html());
|
||||
let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
|
||||
let html = msg.get_id().get_html(&alice).await.unwrap().unwrap();
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
|
||||
// alice: create chat with bob and forward received html-message there
|
||||
let chat = alice.create_chat_with_contact("", "bob@example.net").await;
|
||||
forward_msgs(alice, &[msg.get_id()], chat.get_id())
|
||||
forward_msgs(&alice, &[msg.get_id()], chat.get_id())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = alice.get_last_msg_in(chat.get_id()).await;
|
||||
@@ -469,11 +468,11 @@ test some special html-characters as < > and & but also " and &#x
|
||||
assert!(msg.is_forwarded());
|
||||
assert!(msg.get_text().contains("this is plain"));
|
||||
assert!(msg.has_html());
|
||||
let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
|
||||
let html = msg.get_id().get_html(&alice).await.unwrap().unwrap();
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
|
||||
// bob: check that bob also got the html-part of the forwarded message
|
||||
let bob = &tcm.bob().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
let chat = bob.create_chat_with_contact("", "alice@example.org").await;
|
||||
let msg = bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
assert_eq!(chat.id, msg.chat_id);
|
||||
@@ -482,7 +481,7 @@ test some special html-characters as < > and & but also " and &#x
|
||||
assert!(msg.is_forwarded());
|
||||
assert!(msg.get_text().contains("this is plain"));
|
||||
assert!(msg.has_html());
|
||||
let html = msg.get_id().get_html(bob).await.unwrap().unwrap();
|
||||
let html = msg.get_id().get_html(&bob).await.unwrap().unwrap();
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
}
|
||||
|
||||
@@ -520,11 +519,10 @@ test some special html-characters as < > and & but also " and &#x
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_html_forwarding_encrypted() {
|
||||
let mut tcm = TestContextManager::new();
|
||||
// Alice receives a non-delta html-message
|
||||
// (`ShowEmails=AcceptedContacts` lets Alice actually receive non-delta messages for known
|
||||
// contacts, the contact is marked as known by creating a chat using `chat_with_contact()`)
|
||||
let alice = &tcm.alice().await;
|
||||
let alice = TestContext::new_alice().await;
|
||||
alice
|
||||
.set_config(Config::ShowEmails, Some("1"))
|
||||
.await
|
||||
@@ -533,19 +531,19 @@ test some special html-characters as < > and & but also " and &#x
|
||||
.create_chat_with_contact("", "sender@testrun.org")
|
||||
.await;
|
||||
let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
|
||||
receive_imf(alice, raw, false).await.unwrap();
|
||||
receive_imf(&alice, raw, false).await.unwrap();
|
||||
let msg = alice.get_last_msg_in(chat.get_id()).await;
|
||||
|
||||
// forward the message to saved-messages,
|
||||
// this will encrypt the message as new_alice() has set up keys
|
||||
let chat = alice.get_self_chat().await;
|
||||
forward_msgs(alice, &[msg.get_id()], chat.get_id())
|
||||
forward_msgs(&alice, &[msg.get_id()], chat.get_id())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = alice.pop_sent_msg().await;
|
||||
|
||||
// receive the message on another device
|
||||
let alice = &tcm.alice().await;
|
||||
let alice = TestContext::new_alice().await;
|
||||
alice
|
||||
.set_config(Config::ShowEmails, Some("0"))
|
||||
.await
|
||||
@@ -558,39 +556,38 @@ test some special html-characters as < > and & but also " and &#x
|
||||
assert!(msg.is_forwarded());
|
||||
assert!(msg.get_text().contains("this is plain"));
|
||||
assert!(msg.has_html());
|
||||
let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
|
||||
let html = msg.get_id().get_html(&alice).await.unwrap().unwrap();
|
||||
assert!(html.contains("this is <b>html</b>"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_set_html() {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice = TestContext::new_alice().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
|
||||
// alice sends a message with html-part to bob
|
||||
let chat_id = alice.create_chat(bob).await.id;
|
||||
let chat_id = alice.create_chat(&bob).await.id;
|
||||
let mut msg = Message::new_text("plain text".to_string());
|
||||
msg.set_html(Some("<b>html</b> text".to_string()));
|
||||
assert!(msg.mime_modified);
|
||||
chat::send_msg(alice, chat_id, &mut msg).await.unwrap();
|
||||
chat::send_msg(&alice, chat_id, &mut msg).await.unwrap();
|
||||
|
||||
// check the message is written correctly to alice's db
|
||||
let msg = alice.get_last_msg_in(chat_id).await;
|
||||
assert_eq!(msg.get_text(), "plain text");
|
||||
assert!(!msg.is_forwarded());
|
||||
assert!(msg.mime_modified);
|
||||
let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
|
||||
let html = msg.get_id().get_html(&alice).await.unwrap().unwrap();
|
||||
assert!(html.contains("<b>html</b> text"));
|
||||
|
||||
// let bob receive the message
|
||||
let chat_id = bob.create_chat(alice).await.id;
|
||||
let chat_id = bob.create_chat(&alice).await.id;
|
||||
let msg = bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
assert_eq!(msg.chat_id, chat_id);
|
||||
assert_eq!(msg.get_text(), "plain text");
|
||||
assert!(!msg.is_forwarded());
|
||||
assert!(msg.mime_modified);
|
||||
let html = msg.get_id().get_html(bob).await.unwrap().unwrap();
|
||||
let html = msg.get_id().get_html(&bob).await.unwrap().unwrap();
|
||||
assert!(html.contains("<b>html</b> text"));
|
||||
}
|
||||
|
||||
|
||||
49
src/imap.rs
49
src/imap.rs
@@ -271,14 +271,12 @@ impl Imap {
|
||||
let param = ConfiguredLoginParam::load(context)
|
||||
.await?
|
||||
.context("Not configured")?;
|
||||
let proxy_config = ProxyConfig::load(context).await?;
|
||||
let strict_tls = param.strict_tls(proxy_config.is_some());
|
||||
let imap = Self::new(
|
||||
param.imap.clone(),
|
||||
param.imap_password.clone(),
|
||||
proxy_config,
|
||||
param.proxy_config.clone(),
|
||||
¶m.addr,
|
||||
strict_tls,
|
||||
param.strict_tls(),
|
||||
param.oauth2,
|
||||
idle_interrupt_receiver,
|
||||
);
|
||||
@@ -350,11 +348,10 @@ impl Imap {
|
||||
connection_candidate,
|
||||
)
|
||||
.await
|
||||
.context("IMAP failed to connect")
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
warn!(context, "{err:#}.");
|
||||
warn!(context, "IMAP failed to connect: {err:#}.");
|
||||
first_error.get_or_insert(err);
|
||||
continue;
|
||||
}
|
||||
@@ -507,7 +504,7 @@ impl Imap {
|
||||
}
|
||||
|
||||
let msgs_fetched = self
|
||||
.fetch_new_messages(context, session, watch_folder, folder_meaning)
|
||||
.fetch_new_messages(context, session, watch_folder, folder_meaning, false)
|
||||
.await
|
||||
.context("fetch_new_messages")?;
|
||||
if msgs_fetched && context.get_config_delete_device_after().await?.is_some() {
|
||||
@@ -535,6 +532,7 @@ impl Imap {
|
||||
session: &mut Session,
|
||||
folder: &str,
|
||||
folder_meaning: FolderMeaning,
|
||||
fetch_existing_msgs: bool,
|
||||
) -> Result<bool> {
|
||||
if should_ignore_folder(context, folder, folder_meaning).await? {
|
||||
info!(context, "Not fetching from {folder:?}.");
|
||||
@@ -551,7 +549,7 @@ impl Imap {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if !session.new_mail {
|
||||
if !session.new_mail && !fetch_existing_msgs {
|
||||
info!(context, "No new emails in folder {folder:?}.");
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -560,7 +558,14 @@ impl Imap {
|
||||
let uid_validity = get_uidvalidity(context, folder).await?;
|
||||
let old_uid_next = get_uid_next(context, folder).await?;
|
||||
|
||||
let msgs = session.prefetch(old_uid_next).await.context("prefetch")?;
|
||||
let msgs = if fetch_existing_msgs {
|
||||
session
|
||||
.prefetch_existing_msgs()
|
||||
.await
|
||||
.context("prefetch_existing_msgs")?
|
||||
} else {
|
||||
session.prefetch(old_uid_next).await.context("prefetch")?
|
||||
};
|
||||
let read_cnt = msgs.len();
|
||||
|
||||
let download_limit = context.download_limit().await?;
|
||||
@@ -713,6 +718,7 @@ impl Imap {
|
||||
uids_fetch_in_batch.split_off(0),
|
||||
&uid_message_ids,
|
||||
fetch_partially,
|
||||
fetch_existing_msgs,
|
||||
)
|
||||
.await
|
||||
.context("fetch_many_msgs")?;
|
||||
@@ -777,6 +783,28 @@ impl Imap {
|
||||
.await
|
||||
.context("failed to get recipients from the inbox")?;
|
||||
|
||||
if context.get_config_bool(Config::FetchExistingMsgs).await? {
|
||||
for meaning in [
|
||||
FolderMeaning::Mvbox,
|
||||
FolderMeaning::Inbox,
|
||||
FolderMeaning::Sent,
|
||||
] {
|
||||
let config = match meaning.to_config() {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
if let Some(folder) = context.get_config(config).await? {
|
||||
info!(
|
||||
context,
|
||||
"Fetching existing messages from folder {folder:?}."
|
||||
);
|
||||
self.fetch_new_messages(context, session, &folder, meaning, true)
|
||||
.await
|
||||
.context("could not fetch existing messages")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(context, "Done fetching existing messages.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1303,6 +1331,7 @@ impl Session {
|
||||
/// Returns the last UID fetched successfully and the info about each downloaded message.
|
||||
/// If the message is incorrect or there is a failure to write a message to the database,
|
||||
/// it is skipped and the error is logged.
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub(crate) async fn fetch_many_msgs(
|
||||
&mut self,
|
||||
context: &Context,
|
||||
@@ -1311,6 +1340,7 @@ impl Session {
|
||||
request_uids: Vec<u32>,
|
||||
uid_message_ids: &BTreeMap<u32, String>,
|
||||
fetch_partially: bool,
|
||||
fetching_existing_messages: bool,
|
||||
) -> Result<(Option<u32>, Vec<ReceivedMsg>)> {
|
||||
let mut last_uid = None;
|
||||
let mut received_msgs = Vec::new();
|
||||
@@ -1444,6 +1474,7 @@ impl Session {
|
||||
body,
|
||||
is_seen,
|
||||
partial,
|
||||
fetching_existing_messages,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::cmp;
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
@@ -6,6 +7,7 @@ use async_imap::types::Mailbox;
|
||||
use async_imap::Session as ImapSession;
|
||||
use futures::TryStreamExt;
|
||||
|
||||
use crate::constants::DC_FETCH_EXISTING_MSGS_COUNT;
|
||||
use crate::imap::capabilities::Capabilities;
|
||||
use crate::net::session::SessionStream;
|
||||
|
||||
@@ -141,4 +143,33 @@ impl Session {
|
||||
|
||||
Ok(msgs.into_iter().map(|((_, uid), msg)| (uid, msg)).collect())
|
||||
}
|
||||
|
||||
/// Like prefetch(), but not for new messages but existing ones (the DC_FETCH_EXISTING_MSGS_COUNT newest messages)
|
||||
pub(crate) async fn prefetch_existing_msgs(
|
||||
&mut self,
|
||||
) -> Result<Vec<(u32, async_imap::types::Fetch)>> {
|
||||
let exists: i64 = {
|
||||
let mailbox = self.selected_mailbox.as_ref().context("no mailbox")?;
|
||||
mailbox.exists.into()
|
||||
};
|
||||
|
||||
// Fetch last DC_FETCH_EXISTING_MSGS_COUNT (100) messages.
|
||||
// Sequence numbers are sequential. If there are 1000 messages in the inbox,
|
||||
// we can fetch the sequence numbers 900-1000 and get the last 100 messages.
|
||||
let first = cmp::max(1, exists - DC_FETCH_EXISTING_MSGS_COUNT + 1);
|
||||
let set = format!("{first}:{exists}");
|
||||
let mut list = self
|
||||
.fetch(&set, PREFETCH_FLAGS)
|
||||
.await
|
||||
.context("IMAP Could not fetch")?;
|
||||
|
||||
let mut msgs = BTreeMap::new();
|
||||
while let Some(msg) = list.try_next().await? {
|
||||
if let Some(msg_uid) = msg.uid {
|
||||
msgs.insert((msg.internal_date(), msg_uid), msg);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(msgs.into_iter().map(|((_, uid), msg)| (uid, msg)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use anyhow::{bail, ensure, Result};
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::{self, ChatId};
|
||||
use crate::config::Config;
|
||||
use crate::constants::{ASM_BODY, ASM_SUBJECT};
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::imex::set_self_key;
|
||||
@@ -15,6 +14,7 @@ use crate::message::{Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::SystemMessage;
|
||||
use crate::param::Param;
|
||||
use crate::pgp;
|
||||
use crate::stock_str;
|
||||
use crate::tools::open_file_std;
|
||||
|
||||
/// Initiates key transfer via Autocrypt Setup Message.
|
||||
@@ -32,22 +32,25 @@ pub async fn initiate_key_transfer(context: &Context) -> Result<String> {
|
||||
)?;
|
||||
|
||||
let chat_id = ChatId::create_for_contact(context, ContactId::SELF).await?;
|
||||
let mut msg = Message::new(Viewtype::File);
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::File,
|
||||
..Default::default()
|
||||
};
|
||||
msg.param.set(Param::File, setup_file_blob.as_name());
|
||||
msg.param
|
||||
.set(Param::Filename, "autocrypt-setup-message.html");
|
||||
msg.subject = ASM_SUBJECT.to_owned();
|
||||
msg.subject = stock_str::ac_setup_msg_subject(context).await;
|
||||
msg.param
|
||||
.set(Param::MimeType, "application/autocrypt-setup");
|
||||
msg.param.set_cmd(SystemMessage::AutocryptSetupMessage);
|
||||
msg.force_plaintext();
|
||||
msg.param.set_int(Param::SkipAutocrypt, 1);
|
||||
|
||||
chat::send_msg(context, chat_id, &mut msg).await?;
|
||||
|
||||
// Enable BCC-self, because transferring a key
|
||||
// means we have a multi-device setup.
|
||||
context.set_config_bool(Config::BccSelf, true).await?;
|
||||
|
||||
chat::send_msg(context, chat_id, &mut msg).await?;
|
||||
Ok(setup_code)
|
||||
}
|
||||
|
||||
@@ -110,8 +113,8 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<St
|
||||
);
|
||||
let pgp_msg = encr.replace("-----BEGIN PGP MESSAGE-----", &replacement);
|
||||
|
||||
let msg_subj = ASM_SUBJECT;
|
||||
let msg_body = ASM_BODY.to_string();
|
||||
let msg_subj = stock_str::ac_setup_msg_subject(context).await;
|
||||
let msg_body = stock_str::ac_setup_msg_body(context).await;
|
||||
let msg_body_html = msg_body.replace('\r', "").replace('\n', "<br>");
|
||||
Ok(format!(
|
||||
concat!(
|
||||
@@ -184,6 +187,7 @@ mod tests {
|
||||
|
||||
use crate::pgp::{split_armored_data, HEADER_AUTOCRYPT, HEADER_SETUPCODE};
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::stock_str::StockMessage;
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
use ::pgp::armor::BlockType;
|
||||
|
||||
@@ -209,9 +213,12 @@ mod tests {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_render_setup_file_newline_replace() {
|
||||
let t = TestContext::new_alice().await;
|
||||
t.set_stock_translation(StockMessage::AcSetupMsgBody, "hello\r\nthere".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = render_setup_file(&t, "pw").await.unwrap();
|
||||
println!("{}", &msg);
|
||||
assert!(msg.contains("<p>This is the Autocrypt Setup Message used to transfer your end-to-end setup between clients.<br>"));
|
||||
assert!(msg.contains("<p>hello<br>there</p>"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -45,7 +45,7 @@ use crate::message::Message;
|
||||
use crate::qr::Qr;
|
||||
use crate::stock_str::backup_transfer_msg_body;
|
||||
use crate::tools::{create_id, time, TempPathGuard};
|
||||
use crate::{e2ee, EventType};
|
||||
use crate::EventType;
|
||||
|
||||
use super::{export_backup_stream, export_database, import_backup_stream, DBFILE_BACKUP_NAME};
|
||||
|
||||
@@ -109,11 +109,6 @@ impl BackupProvider {
|
||||
.parent()
|
||||
.context("Context dir not found")?;
|
||||
|
||||
// before we export, make sure the private key exists
|
||||
e2ee::ensure_secret_key_exists(context)
|
||||
.await
|
||||
.context("Cannot create private key or private key not available")?;
|
||||
|
||||
let dbfile = context_dir.join(DBFILE_BACKUP_NAME);
|
||||
if fs::metadata(&dbfile).await.is_ok() {
|
||||
fs::remove_file(&dbfile).await?;
|
||||
|
||||
16
src/key.rs
16
src/key.rs
@@ -456,13 +456,15 @@ impl std::str::FromStr for Fingerprint {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_utils::{alice_keypair, TestContext};
|
||||
|
||||
static KEYPAIR: LazyLock<KeyPair> = LazyLock::new(alice_keypair);
|
||||
static KEYPAIR: Lazy<KeyPair> = Lazy::new(alice_keypair);
|
||||
|
||||
#[test]
|
||||
fn test_from_armored_string() {
|
||||
@@ -612,6 +614,16 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
assert_eq!(key, key2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_self_existing() {
|
||||
let alice = alice_keypair();
|
||||
let t = TestContext::new_alice().await;
|
||||
let pubkey = load_self_public_key(&t).await.unwrap();
|
||||
assert_eq!(alice.public, pubkey);
|
||||
let seckey = load_self_secret_key(&t).await.unwrap();
|
||||
assert_eq!(alice.secret, seckey);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_load_self_generate_public() {
|
||||
let t = TestContext::new().await;
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(crate) mod events;
|
||||
pub use events::*;
|
||||
|
||||
mod aheader;
|
||||
pub mod blob;
|
||||
mod blob;
|
||||
pub mod chat;
|
||||
pub mod chatlist;
|
||||
pub mod config;
|
||||
@@ -68,7 +68,7 @@ mod imap;
|
||||
pub mod imex;
|
||||
pub mod key;
|
||||
pub mod location;
|
||||
pub mod login_param;
|
||||
mod login_param;
|
||||
pub mod message;
|
||||
mod mimefactory;
|
||||
pub mod mimeparser;
|
||||
|
||||
@@ -2,38 +2,23 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::{bail, ensure, format_err, Context as _, Result};
|
||||
use deltachat_contact_tools::{addr_cmp, addr_normalize, EmailAddress};
|
||||
use num_traits::ToPrimitive as _;
|
||||
use anyhow::{format_err, Context as _, Result};
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::configure::server_params::{expand_param_vector, ServerParams};
|
||||
use crate::constants::{DC_LP_AUTH_FLAGS, DC_LP_AUTH_OAUTH2};
|
||||
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;
|
||||
pub use crate::net::proxy::ProxyConfig;
|
||||
pub use crate::provider::Socket;
|
||||
use crate::provider::{get_provider_by_id, Protocol, Provider, UsernamePattern};
|
||||
use crate::net::proxy::ProxyConfig;
|
||||
use crate::provider::{Protocol, Provider, Socket, UsernamePattern};
|
||||
use crate::sql::Sql;
|
||||
use crate::tools::ToOption;
|
||||
|
||||
/// User-entered setting for certificate checks.
|
||||
///
|
||||
/// Should be saved into `imap_certificate_checks` before running configuration.
|
||||
#[derive(
|
||||
Copy,
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
Display,
|
||||
FromPrimitive,
|
||||
ToPrimitive,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
#[derive(Copy, Clone, Debug, Default, Display, FromPrimitive, ToPrimitive, PartialEq, Eq)]
|
||||
#[repr(u32)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum EnteredCertificateChecks {
|
||||
@@ -56,12 +41,10 @@ pub enum EnteredCertificateChecks {
|
||||
}
|
||||
|
||||
/// Values saved into `imap_certificate_checks`.
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Display, FromPrimitive, ToPrimitive, PartialEq, Eq, Serialize, Deserialize,
|
||||
)]
|
||||
#[derive(Copy, Clone, Debug, Display, FromPrimitive, ToPrimitive, PartialEq, Eq)]
|
||||
#[repr(u32)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub(crate) enum ConfiguredCertificateChecks {
|
||||
pub enum ConfiguredCertificateChecks {
|
||||
/// Use configuration from the provider database.
|
||||
/// If there is no provider database setting for certificate checks,
|
||||
/// accept invalid certificates.
|
||||
@@ -95,7 +78,7 @@ pub(crate) enum ConfiguredCertificateChecks {
|
||||
}
|
||||
|
||||
/// Login parameters for a single server, either IMAP or SMTP
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct EnteredServerLoginParam {
|
||||
/// Server hostname or IP address.
|
||||
pub server: String,
|
||||
@@ -118,7 +101,7 @@ pub struct EnteredServerLoginParam {
|
||||
}
|
||||
|
||||
/// Login parameters entered by the user.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct EnteredLoginParam {
|
||||
/// Email address.
|
||||
pub addr: String,
|
||||
@@ -133,13 +116,15 @@ pub struct EnteredLoginParam {
|
||||
/// invalid hostnames
|
||||
pub certificate_checks: EnteredCertificateChecks,
|
||||
|
||||
/// If true, login via OAUTH2 (not recommended anymore)
|
||||
/// Proxy configuration.
|
||||
pub proxy_config: Option<ProxyConfig>,
|
||||
|
||||
pub oauth2: bool,
|
||||
}
|
||||
|
||||
impl EnteredLoginParam {
|
||||
/// Loads entered account settings.
|
||||
pub(crate) async fn load(context: &Context) -> Result<Self> {
|
||||
pub async fn load(context: &Context) -> Result<Self> {
|
||||
let addr = context
|
||||
.get_config(Config::Addr)
|
||||
.await?
|
||||
@@ -211,6 +196,8 @@ impl EnteredLoginParam {
|
||||
.unwrap_or_default();
|
||||
let oauth2 = matches!(server_flags & DC_LP_AUTH_FLAGS, DC_LP_AUTH_OAUTH2);
|
||||
|
||||
let proxy_config = ProxyConfig::load(context).await?;
|
||||
|
||||
Ok(EnteredLoginParam {
|
||||
addr,
|
||||
imap: EnteredServerLoginParam {
|
||||
@@ -228,71 +215,10 @@ impl EnteredLoginParam {
|
||||
password: send_pw,
|
||||
},
|
||||
certificate_checks,
|
||||
proxy_config,
|
||||
oauth2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Saves entered account settings,
|
||||
/// so that they can be prefilled if the user wants to configure the server again.
|
||||
pub(crate) async fn save(&self, context: &Context) -> Result<()> {
|
||||
context.set_config(Config::Addr, Some(&self.addr)).await?;
|
||||
|
||||
context
|
||||
.set_config(Config::MailServer, self.imap.server.to_option())
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::MailPort, self.imap.port.to_option().as_deref())
|
||||
.await?;
|
||||
context
|
||||
.set_config(
|
||||
Config::MailSecurity,
|
||||
self.imap.security.to_i32().to_option().as_deref(),
|
||||
)
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::MailUser, self.imap.user.to_option())
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::MailPw, self.imap.password.to_option())
|
||||
.await?;
|
||||
|
||||
context
|
||||
.set_config(Config::SendServer, self.smtp.server.to_option())
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::SendPort, self.smtp.port.to_option().as_deref())
|
||||
.await?;
|
||||
context
|
||||
.set_config(
|
||||
Config::SendSecurity,
|
||||
self.smtp.security.to_i32().to_option().as_deref(),
|
||||
)
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::SendUser, self.smtp.user.to_option())
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::SendPw, self.smtp.password.to_option())
|
||||
.await?;
|
||||
|
||||
context
|
||||
.set_config(
|
||||
Config::ImapCertificateChecks,
|
||||
self.certificate_checks.to_i32().to_option().as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let server_flags = if self.oauth2 {
|
||||
Some(DC_LP_AUTH_OAUTH2.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
context
|
||||
.set_config(Config::ServerFlags, server_flags.as_deref())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EnteredLoginParam {
|
||||
@@ -393,7 +319,7 @@ impl TryFrom<Socket> for ConnectionSecurity {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct ConfiguredServerLoginParam {
|
||||
pub struct ConfiguredServerLoginParam {
|
||||
pub connection: ConnectionCandidate,
|
||||
|
||||
/// Username.
|
||||
@@ -431,7 +357,7 @@ pub(crate) async fn prioritize_server_login_params(
|
||||
/// Login parameters saved to the database
|
||||
/// after successful configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ConfiguredLoginParam {
|
||||
pub struct ConfiguredLoginParam {
|
||||
/// `From:` address that was used at the time of configuration.
|
||||
pub addr: String,
|
||||
|
||||
@@ -455,29 +381,15 @@ pub(crate) struct ConfiguredLoginParam {
|
||||
|
||||
pub smtp_password: String,
|
||||
|
||||
/// Proxy configuration.
|
||||
pub proxy_config: Option<ProxyConfig>,
|
||||
|
||||
pub provider: Option<&'static Provider>,
|
||||
|
||||
/// TLS options: whether to allow invalid certificates and/or
|
||||
/// invalid hostnames
|
||||
pub certificate_checks: ConfiguredCertificateChecks,
|
||||
|
||||
/// If true, login via OAUTH2 (not recommended anymore)
|
||||
pub oauth2: bool,
|
||||
}
|
||||
|
||||
/// The representation of ConfiguredLoginParam in the database,
|
||||
/// saved as Json.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ConfiguredLoginParamJson {
|
||||
pub addr: String,
|
||||
pub imap: Vec<ConfiguredServerLoginParam>,
|
||||
pub imap_user: String,
|
||||
pub imap_password: String,
|
||||
pub smtp: Vec<ConfiguredServerLoginParam>,
|
||||
pub smtp_user: String,
|
||||
pub smtp_password: String,
|
||||
pub provider_id: Option<String>,
|
||||
pub certificate_checks: ConfiguredCertificateChecks,
|
||||
pub oauth2: bool,
|
||||
}
|
||||
|
||||
@@ -516,27 +428,7 @@ impl ConfiguredLoginParam {
|
||||
/// Load configured account settings from the database.
|
||||
///
|
||||
/// Returns `None` if account is not configured.
|
||||
pub(crate) async fn load(context: &Context) -> Result<Option<Self>> {
|
||||
let Some(self_addr) = context.get_config(Config::ConfiguredAddr).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let json: Option<String> = context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT configured_param FROM transports WHERE addr=?",
|
||||
(&self_addr,),
|
||||
)
|
||||
.await?;
|
||||
if let Some(json) = json {
|
||||
Ok(Some(Self::from_json(&json)?))
|
||||
} else {
|
||||
bail!("Self address {self_addr} doesn't have a corresponding transport");
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads legacy configured param. Only used for tests and the migration.
|
||||
pub(crate) async fn load_legacy(context: &Context) -> Result<Option<Self>> {
|
||||
pub async fn load(context: &Context) -> Result<Option<Self>> {
|
||||
if !context.get_config_bool(Config::Configured).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -789,6 +681,8 @@ impl ConfiguredLoginParam {
|
||||
}];
|
||||
}
|
||||
|
||||
let proxy_config = ProxyConfig::load(context).await?;
|
||||
|
||||
Ok(Some(ConfiguredLoginParam {
|
||||
addr,
|
||||
imap,
|
||||
@@ -799,91 +693,94 @@ impl ConfiguredLoginParam {
|
||||
smtp_password: send_pw,
|
||||
certificate_checks,
|
||||
provider,
|
||||
proxy_config,
|
||||
oauth2,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn save_to_transports_table(
|
||||
self,
|
||||
context: &Context,
|
||||
entered_param: &EnteredLoginParam,
|
||||
) -> Result<()> {
|
||||
let addr = addr_normalize(&self.addr);
|
||||
let configured_addr = context.get_config(Config::ConfiguredAddr).await?;
|
||||
if let Some(configured_addr) = configured_addr {
|
||||
ensure!(
|
||||
addr_cmp(&configured_addr, &addr,),
|
||||
"Adding a second transport is not supported right now."
|
||||
);
|
||||
}
|
||||
/// Save this loginparam to the database.
|
||||
pub async fn save_as_configured_params(&self, context: &Context) -> Result<()> {
|
||||
context.set_primary_self_addr(&self.addr).await?;
|
||||
|
||||
context
|
||||
.sql
|
||||
.set_raw_config(
|
||||
Config::ConfiguredProvider.as_ref(),
|
||||
.set_config(
|
||||
Config::ConfiguredImapServers,
|
||||
Some(&serde_json::to_string(&self.imap)?),
|
||||
)
|
||||
.await?;
|
||||
context
|
||||
.set_config(
|
||||
Config::ConfiguredSmtpServers,
|
||||
Some(&serde_json::to_string(&self.smtp)?),
|
||||
)
|
||||
.await?;
|
||||
|
||||
context
|
||||
.set_config(Config::ConfiguredMailUser, Some(&self.imap_user))
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::ConfiguredMailPw, Some(&self.imap_password))
|
||||
.await?;
|
||||
|
||||
context
|
||||
.set_config(Config::ConfiguredSendUser, Some(&self.smtp_user))
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::ConfiguredSendPw, Some(&self.smtp_password))
|
||||
.await?;
|
||||
|
||||
context
|
||||
.set_config_u32(
|
||||
Config::ConfiguredImapCertificateChecks,
|
||||
self.certificate_checks as u32,
|
||||
)
|
||||
.await?;
|
||||
context
|
||||
.set_config_u32(
|
||||
Config::ConfiguredSmtpCertificateChecks,
|
||||
self.certificate_checks as u32,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Remove legacy settings.
|
||||
context
|
||||
.set_config(Config::ConfiguredMailServer, None)
|
||||
.await?;
|
||||
context.set_config(Config::ConfiguredMailPort, None).await?;
|
||||
context
|
||||
.set_config(Config::ConfiguredMailSecurity, None)
|
||||
.await?;
|
||||
context
|
||||
.set_config(Config::ConfiguredSendServer, None)
|
||||
.await?;
|
||||
context.set_config(Config::ConfiguredSendPort, None).await?;
|
||||
context
|
||||
.set_config(Config::ConfiguredSendSecurity, None)
|
||||
.await?;
|
||||
|
||||
let server_flags = match self.oauth2 {
|
||||
true => DC_LP_AUTH_OAUTH2,
|
||||
false => DC_LP_AUTH_NORMAL,
|
||||
};
|
||||
context
|
||||
.set_config_u32(Config::ConfiguredServerFlags, server_flags as u32)
|
||||
.await?;
|
||||
|
||||
context
|
||||
.set_config(
|
||||
Config::ConfiguredProvider,
|
||||
self.provider.map(|provider| provider.id),
|
||||
)
|
||||
.await?;
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO transports (addr, entered_param, configured_param)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (addr)
|
||||
DO UPDATE SET entered_param=excluded.entered_param, configured_param=excluded.configured_param",
|
||||
(
|
||||
self.addr.clone(),
|
||||
serde_json::to_string(entered_param)?,
|
||||
self.into_json()?,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
context
|
||||
.sql
|
||||
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(&addr))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn from_json(json: &str) -> Result<Self> {
|
||||
let json: ConfiguredLoginParamJson = serde_json::from_str(json)?;
|
||||
|
||||
let provider = json.provider_id.and_then(|id| get_provider_by_id(&id));
|
||||
|
||||
Ok(ConfiguredLoginParam {
|
||||
addr: json.addr,
|
||||
imap: json.imap,
|
||||
imap_user: json.imap_user,
|
||||
imap_password: json.imap_password,
|
||||
smtp: json.smtp,
|
||||
smtp_user: json.smtp_user,
|
||||
smtp_password: json.smtp_password,
|
||||
provider,
|
||||
certificate_checks: json.certificate_checks,
|
||||
oauth2: json.oauth2,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn into_json(self) -> Result<String> {
|
||||
let json = ConfiguredLoginParamJson {
|
||||
addr: self.addr,
|
||||
imap: self.imap,
|
||||
imap_user: self.imap_user,
|
||||
imap_password: self.imap_password,
|
||||
smtp: self.smtp,
|
||||
smtp_user: self.smtp_user,
|
||||
smtp_password: self.smtp_password,
|
||||
provider_id: self.provider.map(|p| p.id.to_string()),
|
||||
certificate_checks: self.certificate_checks,
|
||||
oauth2: self.oauth2,
|
||||
};
|
||||
Ok(serde_json::to_string(&json)?)
|
||||
}
|
||||
|
||||
pub(crate) fn strict_tls(&self, connected_through_proxy: bool) -> bool {
|
||||
pub fn strict_tls(&self) -> bool {
|
||||
let provider_strict_tls = self.provider.map(|provider| provider.opt.strict_tls);
|
||||
match self.certificate_checks {
|
||||
ConfiguredCertificateChecks::OldAutomatic => {
|
||||
provider_strict_tls.unwrap_or(connected_through_proxy)
|
||||
provider_strict_tls.unwrap_or(self.proxy_config.is_some())
|
||||
}
|
||||
ConfiguredCertificateChecks::Automatic => provider_strict_tls.unwrap_or(true),
|
||||
ConfiguredCertificateChecks::Strict => true,
|
||||
@@ -896,10 +793,8 @@ impl ConfiguredLoginParam {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::log::LogExt as _;
|
||||
use crate::provider::get_provider_by_id;
|
||||
use crate::test_utils::TestContext;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_certificate_checks_display() {
|
||||
@@ -944,42 +839,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_save_entered_login_param() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
let param = EnteredLoginParam {
|
||||
addr: "alice@example.org".to_string(),
|
||||
imap: EnteredServerLoginParam {
|
||||
server: "".to_string(),
|
||||
port: 0,
|
||||
security: Socket::Starttls,
|
||||
user: "".to_string(),
|
||||
password: "foobar".to_string(),
|
||||
},
|
||||
smtp: EnteredServerLoginParam {
|
||||
server: "".to_string(),
|
||||
port: 2947,
|
||||
security: Socket::default(),
|
||||
user: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
certificate_checks: Default::default(),
|
||||
oauth2: false,
|
||||
};
|
||||
param.save(&t).await?;
|
||||
assert_eq!(
|
||||
t.get_config(Config::Addr).await?.unwrap(),
|
||||
"alice@example.org"
|
||||
);
|
||||
assert_eq!(t.get_config(Config::MailPw).await?.unwrap(), "foobar");
|
||||
assert_eq!(t.get_config(Config::SendPw).await?, None);
|
||||
assert_eq!(t.get_config_int(Config::SendPort).await?, 2947);
|
||||
|
||||
assert_eq!(EnteredLoginParam::load(&t).await?, param);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_save_load_login_param() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
@@ -1006,37 +865,24 @@ mod tests {
|
||||
}],
|
||||
smtp_user: "".to_string(),
|
||||
smtp_password: "bar".to_string(),
|
||||
// proxy_config is not saved by `save_to_database`, using default value
|
||||
proxy_config: None,
|
||||
provider: None,
|
||||
certificate_checks: ConfiguredCertificateChecks::Strict,
|
||||
oauth2: false,
|
||||
};
|
||||
|
||||
param
|
||||
.clone()
|
||||
.save_to_transports_table(&t, &EnteredLoginParam::default())
|
||||
.await?;
|
||||
let expected_param = r#"{"addr":"alice@example.org","imap":[{"connection":{"host":"imap.example.com","port":123,"security":"Starttls"},"user":"alice"}],"imap_user":"","imap_password":"foo","smtp":[{"connection":{"host":"smtp.example.com","port":456,"security":"Tls"},"user":"alice@example.org"}],"smtp_user":"","smtp_password":"bar","provider_id":null,"certificate_checks":"Strict","oauth2":false}"#;
|
||||
param.save_as_configured_params(&t).await?;
|
||||
assert_eq!(
|
||||
t.sql
|
||||
.query_get_value::<String>("SELECT configured_param FROM transports", ())
|
||||
.await?
|
||||
.unwrap(),
|
||||
expected_param
|
||||
t.get_config(Config::ConfiguredImapServers).await?.unwrap(),
|
||||
r#"[{"connection":{"host":"imap.example.com","port":123,"security":"Starttls"},"user":"alice"}]"#
|
||||
);
|
||||
assert_eq!(t.is_configured().await?, true);
|
||||
t.set_config(Config::Configured, Some("1")).await?;
|
||||
let loaded = ConfiguredLoginParam::load(&t).await?.unwrap();
|
||||
assert_eq!(param, loaded);
|
||||
|
||||
// Legacy ConfiguredImapCertificateChecks config is ignored
|
||||
t.set_config(Config::ConfiguredImapCertificateChecks, Some("999"))
|
||||
.await?;
|
||||
assert!(ConfiguredLoginParam::load(&t).await.is_ok());
|
||||
|
||||
// Test that we don't panic on unknown ConfiguredImapCertificateChecks values.
|
||||
let wrong_param = expected_param.replace("Strict", "Stricct");
|
||||
assert_ne!(expected_param, wrong_param);
|
||||
t.sql
|
||||
.execute("UPDATE transports SET configured_param=?", (wrong_param,))
|
||||
t.set_config(Config::ConfiguredImapCertificateChecks, Some("999"))
|
||||
.await?;
|
||||
assert!(ConfiguredLoginParam::load(&t).await.is_err());
|
||||
|
||||
@@ -1054,8 +900,7 @@ mod tests {
|
||||
t.set_config(Config::Configured, Some("1")).await?;
|
||||
t.set_config(Config::ConfiguredProvider, Some("posteo"))
|
||||
.await?;
|
||||
t.sql
|
||||
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some("alice@posteo.at"))
|
||||
t.set_config(Config::ConfiguredAddr, Some("alice@posteo.at"))
|
||||
.await?;
|
||||
t.set_config(Config::ConfiguredMailServer, Some("posteo.de"))
|
||||
.await?;
|
||||
@@ -1124,73 +969,18 @@ mod tests {
|
||||
],
|
||||
smtp_user: "alice@posteo.de".to_string(),
|
||||
smtp_password: "foobarbaz".to_string(),
|
||||
proxy_config: None,
|
||||
provider: get_provider_by_id("posteo"),
|
||||
certificate_checks: ConfiguredCertificateChecks::Strict,
|
||||
oauth2: false,
|
||||
};
|
||||
|
||||
let loaded = ConfiguredLoginParam::load_legacy(&t).await?.unwrap();
|
||||
assert_eq!(loaded, param);
|
||||
|
||||
migrate_configured_login_param(&t).await;
|
||||
let loaded = ConfiguredLoginParam::load(&t).await?.unwrap();
|
||||
assert_eq!(loaded, param);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_empty_server_list_legacy() -> Result<()> {
|
||||
// Find a provider that does not have server list set.
|
||||
//
|
||||
// There is at least one such provider in the provider database.
|
||||
let (domain, provider) = crate::provider::data::PROVIDER_DATA
|
||||
.iter()
|
||||
.find(|(_domain, provider)| provider.server.is_empty())
|
||||
.unwrap();
|
||||
|
||||
let t = TestContext::new().await;
|
||||
|
||||
let addr = format!("alice@{domain}");
|
||||
|
||||
t.set_config(Config::Configured, Some("1")).await?;
|
||||
t.set_config(Config::ConfiguredProvider, Some(provider.id))
|
||||
.await?;
|
||||
t.sql
|
||||
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(&addr))
|
||||
.await?;
|
||||
t.set_config(Config::ConfiguredMailPw, Some("foobarbaz"))
|
||||
.await?;
|
||||
t.set_config(Config::ConfiguredImapCertificateChecks, Some("1"))
|
||||
.await?; // Strict
|
||||
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
|
||||
.await?;
|
||||
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
|
||||
.await?; // Strict
|
||||
t.set_config(Config::ConfiguredServerFlags, Some("0"))
|
||||
.await?;
|
||||
|
||||
let loaded = ConfiguredLoginParam::load_legacy(&t).await?.unwrap();
|
||||
assert_eq!(loaded.provider, Some(*provider));
|
||||
assert_eq!(loaded.imap.is_empty(), false);
|
||||
assert_eq!(loaded.smtp.is_empty(), false);
|
||||
|
||||
migrate_configured_login_param(&t).await;
|
||||
|
||||
let loaded = ConfiguredLoginParam::load(&t).await?.unwrap();
|
||||
assert_eq!(loaded.provider, Some(*provider));
|
||||
assert_eq!(loaded.imap.is_empty(), false);
|
||||
assert_eq!(loaded.smtp.is_empty(), false);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn migrate_configured_login_param(t: &TestContext) {
|
||||
t.sql.execute("DROP TABLE transports;", ()).await.unwrap();
|
||||
t.sql.set_raw_config_int("dbversion", 130).await.unwrap();
|
||||
t.sql.run_migrations(t).await.log_err(t).ok();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_empty_server_list() -> Result<()> {
|
||||
// Find a provider that does not have server list set.
|
||||
@@ -1205,41 +995,25 @@ mod tests {
|
||||
|
||||
let addr = format!("alice@{domain}");
|
||||
|
||||
ConfiguredLoginParam {
|
||||
addr: addr.clone(),
|
||||
imap: vec![ConfiguredServerLoginParam {
|
||||
connection: ConnectionCandidate {
|
||||
host: "example.org".to_string(),
|
||||
port: 100,
|
||||
security: ConnectionSecurity::Tls,
|
||||
},
|
||||
user: addr.clone(),
|
||||
}],
|
||||
imap_user: addr.clone(),
|
||||
imap_password: "foobarbaz".to_string(),
|
||||
smtp: vec![ConfiguredServerLoginParam {
|
||||
connection: ConnectionCandidate {
|
||||
host: "example.org".to_string(),
|
||||
port: 100,
|
||||
security: ConnectionSecurity::Tls,
|
||||
},
|
||||
user: addr.clone(),
|
||||
}],
|
||||
smtp_user: addr.clone(),
|
||||
smtp_password: "foobarbaz".to_string(),
|
||||
provider: Some(provider),
|
||||
certificate_checks: ConfiguredCertificateChecks::Automatic,
|
||||
oauth2: false,
|
||||
}
|
||||
.save_to_transports_table(&t, &EnteredLoginParam::default())
|
||||
.await?;
|
||||
t.set_config(Config::Configured, Some("1")).await?;
|
||||
t.set_config(Config::ConfiguredProvider, Some(provider.id))
|
||||
.await?;
|
||||
t.set_config(Config::ConfiguredAddr, Some(&addr)).await?;
|
||||
t.set_config(Config::ConfiguredMailPw, Some("foobarbaz"))
|
||||
.await?;
|
||||
t.set_config(Config::ConfiguredImapCertificateChecks, Some("1"))
|
||||
.await?; // Strict
|
||||
t.set_config(Config::ConfiguredSendPw, Some("foobarbaz"))
|
||||
.await?;
|
||||
t.set_config(Config::ConfiguredSmtpCertificateChecks, Some("1"))
|
||||
.await?; // Strict
|
||||
t.set_config(Config::ConfiguredServerFlags, Some("0"))
|
||||
.await?;
|
||||
|
||||
let loaded = ConfiguredLoginParam::load(&t).await?.unwrap();
|
||||
assert_eq!(loaded.provider, Some(*provider));
|
||||
assert_eq!(loaded.imap.is_empty(), false);
|
||||
assert_eq!(loaded.smtp.is_empty(), false);
|
||||
assert_eq!(t.get_configured_provider().await?, Some(*provider));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::chat::{send_msg, Chat, ChatId, ChatIdBlocked, ChatVisibility};
|
||||
use crate::chatlist_events;
|
||||
use crate::config::Config;
|
||||
use crate::constants::{
|
||||
Blocked, Chattype, VideochatType, DC_CHAT_ID_TRASH, DC_MSG_ID_LAST_SPECIAL,
|
||||
Blocked, Chattype, VideochatType, DC_CHAT_ID_TRASH, DC_DESIRED_TEXT_LEN, DC_MSG_ID_LAST_SPECIAL,
|
||||
};
|
||||
use crate::contact::{self, Contact, ContactId};
|
||||
use crate::context::Context;
|
||||
@@ -33,10 +33,9 @@ use crate::reaction::get_msg_reactions;
|
||||
use crate::sql;
|
||||
use crate::summary::Summary;
|
||||
use crate::sync::SyncData;
|
||||
use crate::tools::create_outgoing_rfc724_mid;
|
||||
use crate::tools::{
|
||||
buf_compress, buf_decompress, get_filebytes, get_filemeta, gm2local_offset, read_file,
|
||||
sanitize_filename, time, timestamp_to_str,
|
||||
sanitize_filename, time, timestamp_to_str, truncate,
|
||||
};
|
||||
|
||||
/// Message ID, including reserved IDs.
|
||||
@@ -175,6 +174,15 @@ impl MsgId {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Returns raw text of a message, used for message info
|
||||
pub async fn rawtext(self, context: &Context) -> Result<String> {
|
||||
Ok(context
|
||||
.sql
|
||||
.query_get_value("SELECT txt_raw FROM msgs WHERE id=?", (self,))
|
||||
.await?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Returns server foldernames and UIDs of a message, used for message info
|
||||
pub async fn get_info_server_urls(
|
||||
context: &Context,
|
||||
@@ -211,9 +219,12 @@ impl MsgId {
|
||||
/// Returns detailed message information in a multi-line text form.
|
||||
pub async fn get_info(self, context: &Context) -> Result<String> {
|
||||
let msg = Message::load_from_db(context, self).await?;
|
||||
let rawtxt: String = self.rawtext(context).await?;
|
||||
|
||||
let mut ret = String::new();
|
||||
|
||||
let rawtxt = truncate(rawtxt.trim(), DC_DESIRED_TEXT_LEN);
|
||||
|
||||
let fts = timestamp_to_str(msg.get_timestamp());
|
||||
ret += &format!("Sent: {fts}");
|
||||
|
||||
@@ -324,6 +335,9 @@ impl MsgId {
|
||||
if duration != 0 {
|
||||
ret += &format!("Duration: {duration} ms\n",);
|
||||
}
|
||||
if !rawtxt.is_empty() {
|
||||
ret += &format!("\n{rawtxt}\n");
|
||||
}
|
||||
if !msg.rfc724_mid.is_empty() {
|
||||
ret += &format!("\nMessage-ID: {}", msg.rfc724_mid);
|
||||
|
||||
@@ -471,7 +485,6 @@ impl Message {
|
||||
pub fn new(viewtype: Viewtype) -> Self {
|
||||
Message {
|
||||
viewtype,
|
||||
rfc724_mid: create_outgoing_rfc724_mid(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -481,7 +494,6 @@ impl Message {
|
||||
Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text,
|
||||
rfc724_mid: create_outgoing_rfc724_mid(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -939,51 +951,6 @@ impl Message {
|
||||
self.param.get_cmd()
|
||||
}
|
||||
|
||||
/// Return the contact ID of the profile to open when tapping the info message.
|
||||
pub async fn get_info_contact_id(&self, context: &Context) -> Result<Option<ContactId>> {
|
||||
match self.param.get_cmd() {
|
||||
SystemMessage::GroupNameChanged
|
||||
| SystemMessage::GroupImageChanged
|
||||
| SystemMessage::EphemeralTimerChanged => {
|
||||
if self.from_id != ContactId::INFO {
|
||||
Ok(Some(self.from_id))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
SystemMessage::MemberAddedToGroup | SystemMessage::MemberRemovedFromGroup => {
|
||||
if let Some(contact_i32) = self.param.get_int(Param::ContactAddedRemoved) {
|
||||
let contact_id = ContactId::new(contact_i32.try_into()?);
|
||||
if contact_id == ContactId::SELF
|
||||
|| Contact::real_exists_by_id(context, contact_id).await?
|
||||
{
|
||||
Ok(Some(contact_id))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
SystemMessage::AutocryptSetupMessage
|
||||
| SystemMessage::SecurejoinMessage
|
||||
| SystemMessage::LocationStreamingEnabled
|
||||
| SystemMessage::LocationOnly
|
||||
| SystemMessage::ChatProtectionEnabled
|
||||
| SystemMessage::ChatProtectionDisabled
|
||||
| SystemMessage::InvalidUnencryptedMail
|
||||
| SystemMessage::SecurejoinWait
|
||||
| SystemMessage::SecurejoinWaitTimeout
|
||||
| SystemMessage::MultiDeviceSync
|
||||
| SystemMessage::WebxdcStatusUpdate
|
||||
| SystemMessage::WebxdcInfoMessage
|
||||
| SystemMessage::IrohNodeAddr
|
||||
| SystemMessage::Unknown => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the message is a system message.
|
||||
pub fn is_system_message(&self) -> bool {
|
||||
let cmd = self.param.get_cmd();
|
||||
@@ -1765,10 +1732,6 @@ pub async fn delete_msgs_ex(
|
||||
!delete_for_all || msg.from_id == ContactId::SELF,
|
||||
"Can delete only own messages for others"
|
||||
);
|
||||
ensure!(
|
||||
!delete_for_all || msg.get_showpadlock(),
|
||||
"Cannot request deletion of unencrypted message for others"
|
||||
);
|
||||
|
||||
modified_chat_ids.insert(msg.chat_id);
|
||||
deleted_rfc724_mid.push(msg.rfc724_mid.clone());
|
||||
|
||||
@@ -135,7 +135,7 @@ async fn test_get_width_height() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_quote_basic() {
|
||||
async fn test_quote() {
|
||||
let d = TestContext::new().await;
|
||||
let ctx = &d.ctx;
|
||||
|
||||
@@ -144,10 +144,13 @@ async fn test_quote_basic() {
|
||||
.unwrap();
|
||||
|
||||
let chat = d.create_chat_with_contact("", "dest@example.com").await;
|
||||
|
||||
let mut msg = Message::new_text("Quoted message".to_string());
|
||||
|
||||
// Message has to be sent such that it gets saved to db.
|
||||
chat::send_msg(ctx, chat.id, &mut msg).await.unwrap();
|
||||
// Send message, so it gets a Message-Id.
|
||||
assert!(msg.rfc724_mid.is_empty());
|
||||
let msg_id = chat::send_msg(ctx, chat.id, &mut msg).await.unwrap();
|
||||
let msg = Message::load_from_db(ctx, msg_id).await.unwrap();
|
||||
assert!(!msg.rfc724_mid.is_empty());
|
||||
|
||||
let mut msg2 = Message::new(Viewtype::Text);
|
||||
@@ -355,7 +358,6 @@ async fn test_markseen_msgs() -> Result<()> {
|
||||
let sent1 = alice.send_msg(alice_chat.id, &mut msg).await;
|
||||
let msg1 = bob.recv_msg(&sent1).await;
|
||||
let bob_chat_id = msg1.chat_id;
|
||||
let mut msg = Message::new_text("this is the text!".to_string());
|
||||
let sent2 = alice.send_msg(alice_chat.id, &mut msg).await;
|
||||
let msg2 = bob.recv_msg(&sent2).await;
|
||||
assert_eq!(msg1.chat_id, msg2.chat_id);
|
||||
@@ -378,11 +380,9 @@ async fn test_markseen_msgs() -> Result<()> {
|
||||
|
||||
// bob sends to alice,
|
||||
// alice knows bob and messages appear in normal chat
|
||||
let mut msg = Message::new_text("this is the text!".to_string());
|
||||
let msg1 = alice
|
||||
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
|
||||
.await;
|
||||
let mut msg = Message::new_text("this is the text!".to_string());
|
||||
let msg2 = alice
|
||||
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
|
||||
.await;
|
||||
|
||||
@@ -13,17 +13,14 @@ use mail_builder::headers::HeaderType;
|
||||
use mail_builder::mime::MimePart;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::aheader::{Aheader, EncryptPreference};
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::{self, Chat};
|
||||
use crate::config::Config;
|
||||
use crate::constants::ASM_SUBJECT;
|
||||
use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
|
||||
use crate::contact::{Contact, ContactId, Origin};
|
||||
use crate::context::Context;
|
||||
use crate::e2ee::EncryptHelper;
|
||||
use crate::ephemeral::Timer as EphemeralTimer;
|
||||
use crate::key::DcKey;
|
||||
use crate::location;
|
||||
use crate::message::{self, Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{is_hidden, SystemMessage};
|
||||
@@ -89,15 +86,13 @@ pub struct MimeFactory {
|
||||
/// Vector of pairs of recipient name and address that goes into the `To` field.
|
||||
///
|
||||
/// The list of actual message recipient addresses may be different,
|
||||
/// e.g. if members are hidden for broadcast lists
|
||||
/// or if the keys for some recipients are missing
|
||||
/// and encrypted message cannot be sent to them.
|
||||
/// e.g. if members are hidden for broadcast lists.
|
||||
to: Vec<(String, String)>,
|
||||
|
||||
/// Vector of pairs of past group member names and addresses.
|
||||
past_members: Vec<(String, String)>,
|
||||
|
||||
/// Timestamps of the members in the same order as in the `to`
|
||||
/// Timestamps of the members in the same order as in the `recipients`
|
||||
/// followed by `past_members`.
|
||||
///
|
||||
/// If this is not empty, its length
|
||||
@@ -133,6 +128,7 @@ pub struct RenderedEmail {
|
||||
pub message: String,
|
||||
// pub envelope: Envelope,
|
||||
pub is_encrypted: bool,
|
||||
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
|
||||
@@ -188,6 +184,9 @@ impl MimeFactory {
|
||||
let mut req_mdn = false;
|
||||
|
||||
if chat.is_self_talk() {
|
||||
if msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage {
|
||||
recipients.push(from_addr.to_string());
|
||||
}
|
||||
to.push((from_displayname.to_string(), from_addr.to_string()));
|
||||
} else if chat.is_mailing_list() {
|
||||
let list_post = chat
|
||||
@@ -417,10 +416,12 @@ impl MimeFactory {
|
||||
|
||||
fn should_force_plaintext(&self) -> bool {
|
||||
match &self.loaded {
|
||||
Loaded::Message { msg, .. } => msg
|
||||
.param
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default(),
|
||||
Loaded::Message { chat, msg } => {
|
||||
msg.param
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default()
|
||||
|| chat.typ == Chattype::Broadcast
|
||||
}
|
||||
Loaded::Mdn { .. } => false,
|
||||
}
|
||||
}
|
||||
@@ -434,6 +435,35 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
async fn should_do_gossip(&self, context: &Context, multiple_recipients: bool) -> Result<bool> {
|
||||
match &self.loaded {
|
||||
Loaded::Message { chat, msg } => {
|
||||
let cmd = msg.param.get_cmd();
|
||||
if cmd == SystemMessage::MemberAddedToGroup
|
||||
|| cmd == SystemMessage::SecurejoinMessage
|
||||
{
|
||||
Ok(true)
|
||||
} else if multiple_recipients {
|
||||
// beside key- and member-changes, force a periodic re-gossip.
|
||||
let gossiped_timestamp = chat.id.get_gossiped_timestamp(context).await?;
|
||||
let gossip_period = context.get_config_i64(Config::GossipPeriod).await?;
|
||||
// `gossip_period == 0` is a special case for testing,
|
||||
// enabling gossip in every message.
|
||||
// Otherwise "smeared timestamps" may result in the condition
|
||||
// to fail even if the clock is monotonic.
|
||||
if gossip_period == 0 || time() >= gossiped_timestamp + gossip_period {
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Loaded::Mdn { .. } => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_attach_profile_data(msg: &Message) -> bool {
|
||||
msg.param.get_cmd() != SystemMessage::SecurejoinMessage || {
|
||||
let step = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
@@ -577,7 +607,10 @@ impl MimeFactory {
|
||||
|| to.len() + past_members.len() == self.member_timestamps.len()
|
||||
);
|
||||
if to.is_empty() {
|
||||
to.push(hidden_recipients());
|
||||
to.push(Address::new_group(
|
||||
Some("hidden-recipients".to_string()),
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
|
||||
// Start with Internet Message Format headers in the order of the standard example
|
||||
@@ -680,11 +713,12 @@ impl MimeFactory {
|
||||
|
||||
if let Loaded::Message { chat, .. } = &self.loaded {
|
||||
if chat.typ == Chattype::Broadcast {
|
||||
let encoded_chat_name = encode_words(&chat.name);
|
||||
headers.push((
|
||||
"List-ID",
|
||||
mail_builder::headers::text::Text::new(format!(
|
||||
"{} <{}>",
|
||||
chat.name, chat.grpid
|
||||
mail_builder::headers::raw::Raw::new(format!(
|
||||
"{encoded_chat_name} <{}>",
|
||||
chat.grpid
|
||||
))
|
||||
.into(),
|
||||
));
|
||||
@@ -753,9 +787,13 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
let mut is_gossiped = false;
|
||||
|
||||
let peerstates = self.peerstates_for_recipients(context).await?;
|
||||
let is_encrypted = !self.should_force_plaintext()
|
||||
&& (e2ee_guaranteed || encrypt_helper.should_encrypt(context, &peerstates).await?);
|
||||
&& encrypt_helper
|
||||
.should_encrypt(context, e2ee_guaranteed, &peerstates)
|
||||
.await?;
|
||||
let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded {
|
||||
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
|
||||
} else {
|
||||
@@ -850,23 +888,21 @@ impl MimeFactory {
|
||||
} else if header_name == "to" {
|
||||
protected_headers.push(header.clone());
|
||||
if is_encrypted {
|
||||
let mut to_without_names = to
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter_map(|header| match header {
|
||||
Address::Address(mb) => Some(Address::Address(EmailAddress {
|
||||
name: None,
|
||||
email: mb.email,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if to_without_names.is_empty() {
|
||||
to_without_names.push(hidden_recipients());
|
||||
}
|
||||
unprotected_headers.push((
|
||||
original_header_name,
|
||||
Address::new_list(to_without_names).into(),
|
||||
Address::new_list(
|
||||
to.clone()
|
||||
.into_iter()
|
||||
.filter_map(|header| match header {
|
||||
Address::Address(mb) => Some(Address::Address(EmailAddress {
|
||||
name: None,
|
||||
email: mb.email,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
@@ -920,78 +956,16 @@ impl MimeFactory {
|
||||
// Add gossip headers in chats with multiple recipients
|
||||
let multiple_recipients =
|
||||
peerstates.len() > 1 || context.get_config_bool(Config::BccSelf).await?;
|
||||
|
||||
let gossip_period = context.get_config_i64(Config::GossipPeriod).await?;
|
||||
let now = time();
|
||||
|
||||
match &self.loaded {
|
||||
Loaded::Message { chat, msg } => {
|
||||
if chat.typ != Chattype::Broadcast {
|
||||
for peerstate in peerstates.iter().filter_map(|(state, _)| state.as_ref()) {
|
||||
let Some(key) = peerstate.peek_key(verified) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let fingerprint = key.dc_fingerprint().hex();
|
||||
let cmd = msg.param.get_cmd();
|
||||
let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup
|
||||
|| cmd == SystemMessage::SecurejoinMessage
|
||||
|| multiple_recipients && {
|
||||
let gossiped_timestamp: Option<i64> = context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT timestamp
|
||||
FROM gossip_timestamp
|
||||
WHERE chat_id=? AND fingerprint=?",
|
||||
(chat.id, &fingerprint),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// `gossip_period == 0` is a special case for testing,
|
||||
// enabling gossip in every message.
|
||||
//
|
||||
// If current time is in the past compared to
|
||||
// `gossiped_timestamp`, we also gossip because
|
||||
// either the `gossiped_timestamp` or clock is wrong.
|
||||
gossip_period == 0
|
||||
|| gossiped_timestamp
|
||||
.is_none_or(|ts| now >= ts + gossip_period || now < ts)
|
||||
};
|
||||
|
||||
if !should_do_gossip {
|
||||
continue;
|
||||
}
|
||||
|
||||
let header = Aheader::new(
|
||||
peerstate.addr.clone(),
|
||||
key.clone(),
|
||||
// Autocrypt 1.1.0 specification says that
|
||||
// `prefer-encrypt` attribute SHOULD NOT be included.
|
||||
EncryptPreference::NoPreference,
|
||||
)
|
||||
.to_string();
|
||||
|
||||
message = message.header(
|
||||
"Autocrypt-Gossip",
|
||||
mail_builder::headers::raw::Raw::new(header),
|
||||
);
|
||||
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (chat_id, fingerprint)
|
||||
DO UPDATE SET timestamp=excluded.timestamp",
|
||||
(chat.id, &fingerprint, now),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if self.should_do_gossip(context, multiple_recipients).await? {
|
||||
for peerstate in peerstates.iter().filter_map(|(state, _)| state.as_ref()) {
|
||||
if let Some(header) = peerstate.render_gossip_header(verified) {
|
||||
message = message.header(
|
||||
"Autocrypt-Gossip",
|
||||
mail_builder::headers::raw::Raw::new(header),
|
||||
);
|
||||
is_gossiped = true;
|
||||
}
|
||||
}
|
||||
Loaded::Mdn { .. } => {
|
||||
// Never gossip in MDNs.
|
||||
}
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the inner message.
|
||||
@@ -1013,23 +987,14 @@ impl MimeFactory {
|
||||
Loaded::Mdn { .. } => true,
|
||||
};
|
||||
|
||||
let (encryption_keyring, missing_key_addresses) =
|
||||
encrypt_helper.encryption_keyring(context, verified, &peerstates)?;
|
||||
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>
|
||||
let encrypted = encrypt_helper
|
||||
.encrypt(context, encryption_keyring, message, compress)
|
||||
.encrypt(context, verified, message, peerstates, compress)
|
||||
.await?
|
||||
+ "\n";
|
||||
|
||||
// Remove recipients for which the key is missing.
|
||||
if !missing_key_addresses.is_empty() {
|
||||
self.recipients
|
||||
.retain(|addr| !missing_key_addresses.contains(addr));
|
||||
}
|
||||
|
||||
// Set the appropriate Content-Type for the outer message
|
||||
MimePart::new(
|
||||
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
|
||||
@@ -1135,6 +1100,7 @@ impl MimeFactory {
|
||||
message,
|
||||
// envelope: Envelope::new,
|
||||
is_encrypted,
|
||||
is_gossiped,
|
||||
last_added_location_id,
|
||||
sync_ids_to_delete: self.sync_ids_to_delete,
|
||||
rfc724_mid,
|
||||
@@ -1335,7 +1301,7 @@ impl MimeFactory {
|
||||
mail_builder::headers::raw::Raw::new("v1").into(),
|
||||
));
|
||||
|
||||
placeholdertext = Some(ASM_SUBJECT.to_string());
|
||||
placeholdertext = Some(stock_str::ac_setup_msg_body(context).await);
|
||||
}
|
||||
SystemMessage::SecurejoinMessage => {
|
||||
let step = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
@@ -1667,10 +1633,6 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
fn hidden_recipients() -> Address<'static> {
|
||||
Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
|
||||
}
|
||||
|
||||
async fn build_body_file(context: &Context, msg: &Message) -> Result<MimePart<'static>> {
|
||||
let file_name = msg.get_filename().context("msg has no file")?;
|
||||
let suffix = Path::new(&file_name)
|
||||
@@ -1786,5 +1748,13 @@ fn render_rfc724_mid(rfc724_mid: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/* ******************************************************************************
|
||||
* Encode/decode header words, RFC 2047
|
||||
******************************************************************************/
|
||||
|
||||
fn encode_words(word: &str) -> String {
|
||||
encoded_words::encode(word, None, encoded_words::EncodingFlag::Shortest, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mimefactory_tests;
|
||||
|
||||
@@ -340,12 +340,11 @@ async fn test_subject_in_group() -> Result<()> {
|
||||
|
||||
// 6. Test that in a group, replies also take the quoted message's subject, while non-replies use the group title as subject
|
||||
let t = TestContext::new_alice().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
let group_id = chat::create_group_chat(&t, chat::ProtectionStatus::Unprotected, "groupname") // TODO encodings, ä
|
||||
.await
|
||||
.unwrap();
|
||||
let bob_contact_id = t.add_or_lookup_contact_id(&bob).await;
|
||||
chat::add_contact_to_chat(&t, group_id, bob_contact_id).await?;
|
||||
let bob = Contact::create(&t, "", "bob@example.org").await?;
|
||||
chat::add_contact_to_chat(&t, group_id, bob).await?;
|
||||
|
||||
let subject = send_msg_get_subject(&t, group_id, None).await?;
|
||||
assert_eq!(subject, "groupname");
|
||||
@@ -728,7 +727,6 @@ async fn test_selfavatar_unencrypted_signed() {
|
||||
.is_some());
|
||||
|
||||
// if another message is sent, that one must not contain the avatar
|
||||
let mut msg = Message::new_text("this is the text!".to_string());
|
||||
let sent_msg = t.send_msg(chat.id, &mut msg).await;
|
||||
let mut payload = sent_msg.payload().splitn(4, "\r\n\r\n");
|
||||
|
||||
@@ -774,24 +772,19 @@ async fn test_selfavatar_unencrypted_signed() {
|
||||
/// Test that removed member address does not go into the `To:` field.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_remove_member_bcc() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
|
||||
// Alice creates a group with Bob and Claire and then removes Bob.
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let charlie = &tcm.charlie().await;
|
||||
let alice = TestContext::new_alice().await;
|
||||
|
||||
let bob_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
let charlie_id = alice.add_or_lookup_contact_id(charlie).await;
|
||||
let charlie_contact = Contact::get_by_id(alice, charlie_id).await?;
|
||||
let charlie_addr = charlie_contact.get_addr();
|
||||
let claire_addr = "claire@foo.de";
|
||||
let bob_id = Contact::create(&alice, "Bob", "bob@example.net").await?;
|
||||
let claire_id = Contact::create(&alice, "Claire", claire_addr).await?;
|
||||
|
||||
let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foo").await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, bob_id).await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, charlie_id).await?;
|
||||
send_text_msg(alice, alice_chat_id, "Creating a group".to_string()).await?;
|
||||
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
|
||||
add_contact_to_chat(&alice, alice_chat_id, bob_id).await?;
|
||||
add_contact_to_chat(&alice, alice_chat_id, claire_id).await?;
|
||||
send_text_msg(&alice, alice_chat_id, "Creating a group".to_string()).await?;
|
||||
|
||||
remove_contact_from_chat(alice, alice_chat_id, charlie_id).await?;
|
||||
remove_contact_from_chat(&alice, alice_chat_id, claire_id).await?;
|
||||
let remove = alice.pop_sent_msg().await;
|
||||
let remove_payload = remove.payload();
|
||||
let parsed = mailparse::parse_mail(remove_payload.as_bytes())?;
|
||||
@@ -803,8 +796,8 @@ async fn test_remove_member_bcc() -> Result<()> {
|
||||
for to_addr in to.iter() {
|
||||
match to_addr {
|
||||
mailparse::MailAddr::Single(ref info) => {
|
||||
// Addresses should be of existing members (Alice and Bob) and not Charlie.
|
||||
assert_ne!(info.addr, charlie_addr);
|
||||
// Addresses should be of existing members (Alice and Bob) and not Claire.
|
||||
assert_ne!(info.addr, claire_addr);
|
||||
}
|
||||
mailparse::MailAddr::Group(_) => {
|
||||
panic!("Group addresses are not expected here");
|
||||
@@ -894,7 +887,10 @@ async fn test_dont_remove_self() -> Result<()> {
|
||||
let mime_message = MimeMessage::from_bytes(alice, sent.payload.as_bytes(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!mime_message.header_exists(HeaderDef::ChatGroupPastMembers));
|
||||
assert_eq!(
|
||||
mime_message.get_header(HeaderDef::ChatGroupPastMembers),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
mime_message.chat_group_member_timestamps().unwrap().len(),
|
||||
1 // There is a timestamp for Bob, not for Alice
|
||||
@@ -902,23 +898,3 @@ async fn test_dont_remove_self() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test: mimefactory should never create an empty to header,
|
||||
/// also not if the Selftalk parameter is missing
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_no_empty_to_header() -> Result<()> {
|
||||
let alice = &TestContext::new_alice().await;
|
||||
let mut self_chat = alice.get_self_chat().await;
|
||||
self_chat.param.remove(Param::Selftalk);
|
||||
self_chat.update_param(alice).await?;
|
||||
|
||||
let payload = alice.send_text(self_chat.id, "Hi").await.payload;
|
||||
assert!(
|
||||
// It would be equally fine if the payload contained `To: alice@example.org` or similar,
|
||||
// as long as it's a valid header
|
||||
payload.contains("To: \"hidden-recipients\": ;"),
|
||||
"Payload doesn't contain correct To: header: {payload}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -57,10 +57,6 @@ pub(crate) struct MimeMessage {
|
||||
/// Message headers.
|
||||
headers: HashMap<String, String>,
|
||||
|
||||
#[cfg(test)]
|
||||
/// Names of removed (ignored) headers. Used by `header_exists()` needed for tests.
|
||||
headers_removed: HashSet<String>,
|
||||
|
||||
/// List of addresses from the `To` and `Cc` headers.
|
||||
///
|
||||
/// Addresses are normalized and lowercase.
|
||||
@@ -240,7 +236,6 @@ impl MimeMessage {
|
||||
let mut hop_info = parse_receive_headers(&mail.get_headers());
|
||||
|
||||
let mut headers = Default::default();
|
||||
let mut headers_removed = HashSet::<String>::new();
|
||||
let mut recipients = Default::default();
|
||||
let mut past_members = Default::default();
|
||||
let mut from = Default::default();
|
||||
@@ -258,12 +253,7 @@ impl MimeMessage {
|
||||
&mut chat_disposition_notification_to,
|
||||
&mail.headers,
|
||||
);
|
||||
headers.retain(|k, _| {
|
||||
!is_hidden(k) || {
|
||||
headers_removed.insert(k.clone());
|
||||
false
|
||||
}
|
||||
});
|
||||
headers.retain(|k, _| !is_hidden(k));
|
||||
|
||||
// Parse hidden headers.
|
||||
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
|
||||
@@ -274,7 +264,7 @@ impl MimeMessage {
|
||||
// messages are shown as unencrypted anyway.
|
||||
|
||||
timestamp_sent =
|
||||
Self::get_timestamp_sent(&part.headers, timestamp_sent, timestamp_rcvd);
|
||||
Self::get_timestamp_sent(&mail.headers, timestamp_sent, timestamp_rcvd);
|
||||
MimeMessage::merge_headers(
|
||||
context,
|
||||
&mut headers,
|
||||
@@ -308,11 +298,9 @@ impl MimeMessage {
|
||||
// Overwrite Message-ID with X-Microsoft-Original-Message-ID.
|
||||
// However if we later find Message-ID in the protected part,
|
||||
// it will overwrite both.
|
||||
if let Some(microsoft_message_id) = remove_header(
|
||||
&mut headers,
|
||||
HeaderDef::XMicrosoftOriginalMessageId.get_headername(),
|
||||
&mut headers_removed,
|
||||
) {
|
||||
if let Some(microsoft_message_id) =
|
||||
headers.remove(HeaderDef::XMicrosoftOriginalMessageId.get_headername())
|
||||
{
|
||||
headers.insert(
|
||||
HeaderDef::MessageId.get_headername().to_string(),
|
||||
microsoft_message_id,
|
||||
@@ -321,7 +309,7 @@ impl MimeMessage {
|
||||
|
||||
// Remove headers that are allowed _only_ in the encrypted+signed part. It's ok to leave
|
||||
// them in signed-only emails, but has no value currently.
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed);
|
||||
Self::remove_secured_headers(&mut headers);
|
||||
|
||||
let mut from = from.context("No from in message")?;
|
||||
let private_keyring = load_self_secret_keyring(context).await?;
|
||||
@@ -357,13 +345,6 @@ impl MimeMessage {
|
||||
}
|
||||
|
||||
decrypted_msg = Some(msg);
|
||||
|
||||
timestamp_sent = Self::get_timestamp_sent(
|
||||
&decrypted_mail.headers,
|
||||
timestamp_sent,
|
||||
timestamp_rcvd,
|
||||
);
|
||||
|
||||
if let Some(protected_aheader_value) = decrypted_mail
|
||||
.headers
|
||||
.get_header_value(HeaderDef::Autocrypt)
|
||||
@@ -435,27 +416,13 @@ impl MimeMessage {
|
||||
content
|
||||
});
|
||||
if let (Ok(mail), true) = (mail, encrypted) {
|
||||
timestamp_sent =
|
||||
Self::get_timestamp_sent(&mail.headers, timestamp_sent, timestamp_rcvd);
|
||||
if !signatures.is_empty() {
|
||||
// Remove unsigned opportunistically protected headers from messages considered
|
||||
// Autocrypt-encrypted / displayed with padlock.
|
||||
// For "Subject" see <https://github.com/deltachat/deltachat-core-rust/issues/1790>.
|
||||
for h in [
|
||||
HeaderDef::Subject,
|
||||
HeaderDef::ChatGroupId,
|
||||
HeaderDef::ChatGroupName,
|
||||
HeaderDef::ChatGroupNameChanged,
|
||||
HeaderDef::ChatGroupNameTimestamp,
|
||||
HeaderDef::ChatGroupAvatar,
|
||||
HeaderDef::ChatGroupMemberRemoved,
|
||||
HeaderDef::ChatGroupMemberAdded,
|
||||
HeaderDef::ChatGroupMemberTimestamps,
|
||||
HeaderDef::ChatGroupPastMembers,
|
||||
HeaderDef::ChatDelete,
|
||||
HeaderDef::ChatEdit,
|
||||
HeaderDef::ChatUserAvatar,
|
||||
] {
|
||||
remove_header(&mut headers, h.get_headername(), &mut headers_removed);
|
||||
}
|
||||
headers.retain(|k, _| !(k.starts_with("chat-") || k == "subject"));
|
||||
}
|
||||
|
||||
// let known protected headers from the decrypted
|
||||
@@ -518,7 +485,7 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
if signatures.is_empty() {
|
||||
Self::remove_secured_headers(&mut headers, &mut headers_removed);
|
||||
Self::remove_secured_headers(&mut headers);
|
||||
|
||||
// If it is not a read receipt, degrade encryption.
|
||||
if let (Some(peerstate), Ok(mail)) = (&mut peerstate, mail) {
|
||||
@@ -542,9 +509,6 @@ impl MimeMessage {
|
||||
let mut parser = MimeMessage {
|
||||
parts: Vec::new(),
|
||||
headers,
|
||||
#[cfg(test)]
|
||||
headers_removed,
|
||||
|
||||
recipients,
|
||||
past_members,
|
||||
list_post,
|
||||
@@ -946,16 +910,6 @@ impl MimeMessage {
|
||||
.map(|s| s.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Returns whether the header exists in any part of the parsed message.
|
||||
///
|
||||
/// Use this to check for header absense. Header presense should be checked using
|
||||
/// `get_header(...).is_some()` as it also checks that the header isn't ignored.
|
||||
pub(crate) fn header_exists(&self, headerdef: HeaderDef) -> bool {
|
||||
let hname = headerdef.get_headername();
|
||||
self.headers.contains_key(hname) || self.headers_removed.contains(hname)
|
||||
}
|
||||
|
||||
/// Returns `Chat-Group-ID` header value if it is a valid group ID.
|
||||
pub fn get_chat_group_id(&self) -> Option<&str> {
|
||||
self.get_header(HeaderDef::ChatGroupId)
|
||||
@@ -1551,17 +1505,14 @@ impl MimeMessage {
|
||||
.and_then(|msgid| parse_message_id(msgid).ok())
|
||||
}
|
||||
|
||||
fn remove_secured_headers(
|
||||
headers: &mut HashMap<String, String>,
|
||||
removed: &mut HashSet<String>,
|
||||
) {
|
||||
remove_header(headers, "secure-join-fingerprint", removed);
|
||||
remove_header(headers, "secure-join-auth", removed);
|
||||
remove_header(headers, "chat-verified", removed);
|
||||
remove_header(headers, "autocrypt-gossip", removed);
|
||||
fn remove_secured_headers(headers: &mut HashMap<String, String>) {
|
||||
headers.remove("secure-join-fingerprint");
|
||||
headers.remove("secure-join-auth");
|
||||
headers.remove("chat-verified");
|
||||
headers.remove("autocrypt-gossip");
|
||||
|
||||
// Secure-Join is secured unless it is an initial "vc-request"/"vg-request".
|
||||
if let Some(secure_join) = remove_header(headers, "secure-join", removed) {
|
||||
if let Some(secure_join) = headers.remove("secure-join") {
|
||||
if secure_join == "vc-request" || secure_join == "vg-request" {
|
||||
headers.insert("secure-join".to_string(), secure_join);
|
||||
}
|
||||
@@ -1889,19 +1840,6 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_header(
|
||||
headers: &mut HashMap<String, String>,
|
||||
key: &str,
|
||||
removed: &mut HashSet<String>,
|
||||
) -> Option<String> {
|
||||
if let Some((k, v)) = headers.remove_entry(key) {
|
||||
removed.insert(k);
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `Autocrypt-Gossip` headers from the email and applies them to peerstates.
|
||||
/// Params:
|
||||
/// from: The address which sent the message currently being parsed
|
||||
|
||||
@@ -1418,6 +1418,25 @@ async fn test_x_microsoft_original_message_id_precedence() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_extra_imf_chat_header() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.alice().await;
|
||||
let chat_id = t.get_self_chat().await.id;
|
||||
|
||||
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
|
||||
let sent_msg = t.pop_sent_msg().await;
|
||||
let payload = sent_msg
|
||||
.payload
|
||||
.replace("Message-ID:", "Chat-Forty-Two: 42\r\nMessage-ID:");
|
||||
let msg = MimeMessage::from_bytes(t, payload.as_bytes(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.headers.contains_key("chat-version"));
|
||||
assert!(!msg.headers.contains_key("chat-forty-two"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_long_in_reply_to() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
@@ -1982,32 +2001,3 @@ async fn test_chat_edit_imf_header() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that timestamp of signed but not encrypted message is protected.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_protected_date() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
alice.set_config(Config::SignUnencrypted, Some("1")).await?;
|
||||
|
||||
let alice_chat = alice.create_email_chat(bob).await;
|
||||
let alice_msg_id = chat::send_text_msg(alice, alice_chat.id, "Hello!".to_string()).await?;
|
||||
let alice_msg = Message::load_from_db(alice, alice_msg_id).await?;
|
||||
assert_eq!(alice_msg.get_showpadlock(), false);
|
||||
|
||||
let mut sent_msg = alice.pop_sent_msg().await;
|
||||
sent_msg.payload = sent_msg.payload.replacen(
|
||||
"Date:",
|
||||
"Date: Wed, 17 Mar 2021 14:30:53 +0100 (CET)\r\nX-Not-Date:",
|
||||
1,
|
||||
);
|
||||
let bob_msg = bob.recv_msg(&sent_msg).await;
|
||||
assert_eq!(alice_msg.get_text(), bob_msg.get_text());
|
||||
|
||||
// Timestamp that the sender has put into the message
|
||||
// should always be displayed as is on the receiver.
|
||||
assert_eq!(alice_msg.get_timestamp(), bob_msg.get_timestamp());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ use anyhow::{Context as _, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::sync::LazyLock;
|
||||
use tokio::net::lookup_host;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::load_connection_timestamp;
|
||||
use crate::context::Context;
|
||||
use crate::tools::time;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
/// Inserts entry into DNS cache
|
||||
/// or updates existing one with a new timestamp.
|
||||
@@ -90,8 +90,8 @@ pub(crate) async fn prune_dns_cache(context: &Context) -> Result<()> {
|
||||
/// <https://docs.rs/tokio/1.40.0/tokio/sync/struct.Mutex.html#which-kind-of-mutex-should-you-use>
|
||||
/// and
|
||||
/// <https://stackoverflow.com/questions/63712823/why-do-i-get-a-deadlock-when-using-tokio-with-a-stdsyncmutex>.
|
||||
static LOOKUP_HOST_CACHE: LazyLock<parking_lot::RwLock<HashMap<String, Vec<IpAddr>>>> =
|
||||
LazyLock::new(Default::default);
|
||||
static LOOKUP_HOST_CACHE: Lazy<parking_lot::RwLock<HashMap<String, Vec<IpAddr>>>> =
|
||||
Lazy::new(Default::default);
|
||||
|
||||
/// Wrapper for `lookup_host` that returns IP addresses.
|
||||
async fn lookup_ips(host: impl tokio::net::ToSocketAddrs) -> Result<impl Iterator<Item = IpAddr>> {
|
||||
@@ -229,7 +229,7 @@ pub(crate) async fn update_connect_timestamp(
|
||||
///
|
||||
/// See <https://support.delta.chat/t/no-dns-resolution-result/2778> and
|
||||
/// <https://github.com/deltachat/deltachat-core-rust/issues/4920> for reasons.
|
||||
static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new(|| {
|
||||
static DNS_PRELOAD: Lazy<HashMap<&'static str, Vec<IpAddr>>> = Lazy::new(|| {
|
||||
HashMap::from([
|
||||
(
|
||||
"mail.sangham.net",
|
||||
|
||||
@@ -154,21 +154,18 @@ impl Socks5Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the proxy through which all traffic
|
||||
/// (except for iroh p2p connections)
|
||||
/// will be sent.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProxyConfig {
|
||||
/// HTTP proxy.
|
||||
// HTTP proxy.
|
||||
Http(HttpConfig),
|
||||
|
||||
/// HTTPS proxy.
|
||||
// HTTPS proxy.
|
||||
Https(HttpConfig),
|
||||
|
||||
/// SOCKS5 proxy.
|
||||
// SOCKS5 proxy.
|
||||
Socks5(Socks5Config),
|
||||
|
||||
/// Shadowsocks proxy.
|
||||
// Shadowsocks proxy.
|
||||
Shadowsocks(ShadowsocksConfig),
|
||||
}
|
||||
|
||||
@@ -249,7 +246,7 @@ where
|
||||
|
||||
impl ProxyConfig {
|
||||
/// Creates a new proxy configuration by parsing given proxy URL.
|
||||
pub fn from_url(url: &str) -> Result<Self> {
|
||||
pub(crate) fn from_url(url: &str) -> Result<Self> {
|
||||
let url = Url::parse(url).context("Cannot parse proxy URL")?;
|
||||
match url.scheme() {
|
||||
"http" => {
|
||||
@@ -308,7 +305,7 @@ impl ProxyConfig {
|
||||
///
|
||||
/// This function can be used to normalize proxy URL
|
||||
/// by parsing it and serializing back.
|
||||
pub fn to_url(&self) -> String {
|
||||
pub(crate) fn to_url(&self) -> String {
|
||||
match self {
|
||||
Self::Http(http_config) => http_config.to_url("http"),
|
||||
Self::Https(http_config) => http_config.to_url("https"),
|
||||
@@ -394,7 +391,7 @@ impl ProxyConfig {
|
||||
|
||||
/// 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(crate) async fn connect(
|
||||
pub async fn connect(
|
||||
&self,
|
||||
context: &Context,
|
||||
target_host: &str,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user