Compare commits

...

79 Commits

Author SHA1 Message Date
link2xt
81f6aec1a0 chore(release): prepare for 1.139.4 2024-05-21 18:09:55 +00:00
iequidoo
ff60605a7f test: import_vcard() updates only the contact's gossip key 2024-05-21 17:40:07 +00:00
iequidoo
7010e80336 fix: make_vcard: Add authname and key for ContactId::SELF 2024-05-21 17:40:07 +00:00
iequidoo
5f790c1dbc fix(contact-tools): parse_vcard: Support \r\n newlines 2024-05-21 17:40:07 +00:00
iequidoo
8c5d8477fb feat: Add import_vcard() (#5202)
Add a function importing contacts from the given vCard.
2024-05-21 17:40:07 +00:00
iequidoo
10fe6929b0 feat: Scale up contact origins to OutgoingTo when sending a message 2024-05-21 17:40:07 +00:00
link2xt
6fc0000c8a fix: do not log warning if iroh relay metadata is NIL 2024-05-21 15:24:08 +00:00
Sebastian Klähn
e84a5589df nix: add nextest (#5610)
Has some unrelated change that I add the whole .vscode folder to
.gitignore which I also need.
2024-05-21 08:18:05 +00:00
link2xt
e7d9ff12ec chore(release): prepare for 1.139.3 2024-05-20 18:19:27 +00:00
link2xt
607f5959ab fix: always convert absolute paths to relative in accounts.toml
Even if the path does not start with
the directory containing the config,
we want to keep only the last component.
2024-05-20 17:49:21 +00:00
Simon Laux
11546a1ce9 api!(npm rpc server): change api: don't search in path unless options.takeVersionFromPATH is set to true
-remove `DELTA_CHAT_SKIP_PATH` environment variable
- remove version check / search for dc rpc server in $PATH
- remove `options.skipSearchInPath`
- add `options.takeVersionFromPATH`
2024-05-20 18:55:05 +02:00
Simon Laux
ee671836ca fix: npm rpc: set default options for startDeltaChat
this fixes an "undefined" error
2024-05-20 18:55:05 +02:00
Simon Laux
dd77d32446 fix: log/print exit error of deltachat-rpc-server (#5601)
see also #5599 (this logs that error atleast, but does not fix it yet)

---------

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2024-05-20 10:47:36 +00:00
link2xt
b32fb05ab8 fix: ignore event channel overflows
async-broadcast returns Overflowed error once
if channel overflow happened.
Public APIs such as get_next_event JSON-RPC method
are only expecting an error if the channel is closed,
so we should not propagate overflow error outside.
In particular, Delta Chat Desktop
stop receiving events completely if an error
is returned once.
If overflow happens, we should ignore it
and try again until we get an event or an error because
the channel is closed (in case of recv())
or empty (in case of try_recv()).
2024-05-20 10:44:35 +00:00
link2xt
918d87dcb6 refactor: do not try to lookup group in create_or_lookup_group() 2024-05-20 05:20:38 +00:00
link2xt
98ae05ee59 refactor: stop trying to extract chat ID from Message-IDs 2024-05-20 05:20:38 +00:00
link2xt
cff5c064a6 refactor: use let..else in create_or_lookup_group() 2024-05-20 05:20:38 +00:00
link2xt
e9cef4b0ba refactor(receive_imf): only call create_or_lookup_group() with allow_creation=true 2024-05-20 05:20:38 +00:00
link2xt
7f2c8ff53d refactor(receive_imf): remove unnecessary check for is_mdn
If message is an MDN, it is already assigned to the trash chat
by now, so it is enough to check if the message
is not assigned to chat yet.
2024-05-20 05:20:38 +00:00
link2xt
46d6b81058 refactor(receive_imf): do not check for ContactId::UNDEFINED
from_id should not be undefined,
we either should create a contact for it by then
or reject the message.
2024-05-20 05:20:38 +00:00
link2xt
6d59fb49aa feat: replace env_logger with tracing_subscriber
This allows to get iroh logs with
RUST_LOG=iroh_net=trace
2024-05-19 23:22:37 +00:00
Simon Laux
97602f3fd7 fix: sql syntax error in db migration 27 2024-05-19 20:15:22 +02:00
Simon Laux
f17987743e fix: db migration version 59, it contained an sql syntax error 2024-05-19 20:15:22 +02:00
link2xt
5767cce178 fix(mimeparser): take the last header of multiple ones with the same name
If multiple headers with the same name are present,
we must take the last one.
This is the one that is DKIM-signed if
this header is DKIM-signed at all.

Ideally servers should prevent adding
more From, To and Cc headers by oversigning
them, but unfortunately it is not common
and OpenDKIM in its default configuration
does not oversign any headers.
2024-05-18 22:24:39 +00:00
link2xt
20a4bb1a88 api(deltachat-rpc-client): add Account.wait_for_incoming_msg() 2024-05-18 22:24:17 +00:00
link2xt
578f29f215 chore(release): prepare for 1.139.2 2024-05-18 20:58:03 +00:00
link2xt
6c9643e39e build: add repository URL to @deltachat/jsonrpc-client 2024-05-18 20:56:11 +00:00
link2xt
502ae7fd9f chore(release): prepare for 1.139.1 2024-05-18 20:40:34 +00:00
link2xt
8cb527342a ci: set --access public when publishing to npm
Otherwise it fails to upload new packages with provenance.
2024-05-18 20:39:41 +00:00
link2xt
964c943dd9 chore(release): prepare for 1.139.0 2024-05-18 19:42:54 +00:00
Simon Laux
a971ad1f85 npm rpc server: fix example (#5580) 2024-05-18 19:58:44 +02:00
link2xt
e66b9de922 fix: do not mark the message as seen if it has location.kml 2024-05-18 16:09:55 +00:00
adbenitez
5db202169b fix: save override sender displayname for outgoing messages
Co-authored-by: link2xt <link2xt@testrun.org>
2024-05-18 16:08:53 +00:00
link2xt
b292b191ff test(deltachat-rpc-client): test sending vCard 2024-05-18 14:00:57 +00:00
link2xt
450ff411ec api(deltachat-rpc-client): add Chat.send_contact() 2024-05-18 14:00:57 +00:00
link2xt
8de92e54eb api(deltachat-rpc-client): add Contact.make_vcard() 2024-05-18 14:00:57 +00:00
link2xt
d0844c3e62 api(deltachat-rpc-client): add ViewType.VCARD constant 2024-05-18 14:00:57 +00:00
link2xt
37d61e41ca api(deltachat-jsonrpc): return vcard contact directly in MessageObject 2024-05-17 23:37:29 +00:00
Simon Laux
0c7dad961d npm rpc: fix convert_platform.py: 32bit i32 -> ia32 (#5589) 2024-05-17 23:35:50 +02:00
Sebastian Klähn
36f1fc4f9d feat: ephemeral peer channels (#5346)
Co-authored-by: link2xt <link2xt@testrun.org>
Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2024-05-17 20:13:21 +00:00
Simon Laux
517cb821fb jsonrpc: add api migrate_account and get_blob_dir (#5584)
closes #5533

adds the functions that were still missing for migration to jsonrpc (the
ones that the cffi already had, so just should be quick to review ;)
2024-05-17 21:33:59 +02:00
Simon Laux
ef6c3f8476 fix: rpc npm: fix local desktop development (#5583)
typescript was complaining about missing `@deltachat/jsonrpc-client`
when it wasn't installed locally
2024-05-17 21:28:33 +02:00
link2xt
f84f0d5ad9 ci: check that constants are always up-to-date 2024-05-17 19:23:27 +00:00
Simon Laux
d8e98279c4 rpc npm: rename shutdown method to close and add muteStdErr option to mute the stderr output (#5588) 2024-05-17 21:14:38 +02:00
link2xt
424ac606d8 ci: publish @deltachat/jsonrpc-client directly to npm 2024-05-17 19:10:39 +00:00
Sebastian Klähn
2f35d9a013 use rust-analyzer nightly 2024-05-17 17:37:51 +00:00
Sebastian Klähn
e5259176c9 nix: add git-cliff to flake 2024-05-17 17:36:02 +00:00
link2xt
c370195698 chore(cargo): downgrade libc from 0.2.154 to 0.2.153
0.2.154 is yanked.
2024-05-17 13:52:19 +00:00
Simon Laux
0ba0bd3d77 fix(@deltachat/stdio-rpc-server): fix version check when deltachat-rpc-server is found in path (#5579) 2024-05-17 11:11:11 +02:00
link2xt
d23a7b8523 chore(release): prepare for 1.138.5 2024-05-16 15:07:46 +00:00
link2xt
935f503bc7 chore: rebuild node constants 2024-05-16 15:01:56 +00:00
link2xt
a0f0a8e021 build: add repository URL to deltachat-rpc-server packages 2024-05-16 14:59:45 +00:00
iequidoo
6290ed8752 api: Add make_vcard() (#5203)
Add a function returning a vCard containing contacts with the given ids.
2024-05-15 21:49:59 -03:00
iequidoo
a38f0ba09e refactor: VcardContact: Change timestamp type to i64
- u64 only adds unnecessary conversions.
- `Contact::last_seen` is also `i64`, so make timestamps such everywhere.
2024-05-15 21:07:24 -03:00
iequidoo
191624f334 refactor(contact-tools): VcardContact: rename display_name to authname
It's actually `deltachat::contact::Contact::authname` by semantics. `display_name`/`name` shouldn't
be shared, it's the name given by the user locally.
2024-05-15 21:07:24 -03:00
Hocuri
5292a49bb1 fix: parsing vCards with avatars exported by Android's "Contacts" app 2024-05-15 21:07:24 -03:00
iequidoo
22f01a2699 api: Add Viewtype::Vcard (#5202)
Co-authored-by: Hocuri <hocuri@gmx.de>
2024-05-15 21:07:24 -03:00
iequidoo
95238b6e17 api(jsonrpc): Add parse_vcard() (#5202)
Add a function parsing a vCard file at the given path.

Co-authored-by: Hocuri <hocuri@gmx.de>
Co-authored-by: Asiel Díaz Benítez <asieldbenitez@gmail.com>
2024-05-15 21:07:24 -03:00
link2xt
4a738ebd19 chore(release): prepare for 1.138.4 2024-05-15 21:59:28 +00:00
link2xt
d02eccd303 ci: run actions/setup-node before npm publish
Otherwise it does not try to use NODE_AUTH_TOKEN.
2024-05-15 21:57:24 +00:00
link2xt
f1fa053f9f chore(release): prepare for 1.138.3 2024-05-15 20:42:34 +00:00
link2xt
38c1caf180 ci: give CI job permission to publish binaries to the release
Otherwise it fails on `gh release upload` step.
2024-05-15 20:38:59 +00:00
link2xt
97d2812644 chore(release): prepare for 1.138.2 2024-05-15 18:34:06 +00:00
link2xt
2ab713d968 ci: add npm token to publish deltachat-rpc-server packages 2024-05-15 18:08:58 +00:00
link2xt
b7a25d5092 api(deltachat-rpc-client): add CONFIG_SYNCED constant 2024-05-15 17:20:06 +00:00
link2xt
8cd85fa7a4 feat: reset more settings when configuring a chatmail account 2024-05-15 06:30:17 +00:00
link2xt
7cfab9a931 test: set configuration after configure() finishes
This allows to overwrite settings changed
when XCHATMAIL capability is detected.
2024-05-15 06:27:57 +00:00
link2xt
30086038e6 chore(release): prepare for 1.138.1 2024-05-14 22:25:13 +00:00
link2xt
eec1062619 feat: detect XCHATMAIL capability and expose it as is_chatmail config 2024-05-14 22:17:46 +00:00
link2xt
07ceabdf85 refactor: resultify token::lookup_or_new() 2024-05-14 20:32:23 +00:00
Simon Laux
c349bf8e0c ci(deltachat-rpc-server): fix upload of npm packages to github releases (#5564) 2024-05-14 18:48:34 +00:00
link2xt
21eb4f6648 chore(cargo): bump brotli from 5.0.0 to 6.0.0 2024-05-14 17:27:33 +00:00
dependabot[bot]
10fed7d7de chore(cargo): bump human-panic from 1.2.3 to 2.0.0
Bumps [human-panic](https://github.com/rust-cli/human-panic) from 1.2.3 to 2.0.0.
- [Changelog](https://github.com/rust-cli/human-panic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/human-panic/compare/v1.2.3...v2.0.0)

---
updated-dependencies:
- dependency-name: human-panic
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 16:29:22 +00:00
dependabot[bot]
b08a283fe5 chore(cargo): bump serde_json from 1.0.115 to 1.0.116
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.115 to 1.0.116.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.115...v1.0.116)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 16:12:52 +00:00
dependabot[bot]
45a2805100 chore(cargo): bump hickory-resolver from 0.24.0 to 0.24.1
Bumps [hickory-resolver](https://github.com/hickory-dns/hickory-dns) from 0.24.0 to 0.24.1.
- [Release notes](https://github.com/hickory-dns/hickory-dns/releases)
- [Changelog](https://github.com/hickory-dns/hickory-dns/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hickory-dns/hickory-dns/compare/v0.24.0...v0.24.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 16:11:48 +00:00
dependabot[bot]
cc8157ecf1 chore(cargo): bump libc from 0.2.153 to 0.2.154
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.153 to 0.2.154.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.153...0.2.154)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 16:11:30 +00:00
dependabot[bot]
0c98aca5f0 chore(cargo): bump parking_lot from 0.12.1 to 0.12.2
Bumps [parking_lot](https://github.com/Amanieu/parking_lot) from 0.12.1 to 0.12.2.
- [Changelog](https://github.com/Amanieu/parking_lot/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Amanieu/parking_lot/compare/0.12.1...0.12.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-14 16:11:12 +00:00
link2xt
170e4b3530 refactor(sql): make open flags immutable 2024-05-14 14:55:04 +00:00
link2xt
5ed91e9f6e refactor: make MimeMessage.get_header() return Option<&str> 2024-05-13 17:07:58 +00:00
91 changed files with 4223 additions and 722 deletions

View File

@@ -40,6 +40,18 @@ jobs:
- name: Check
run: cargo check --workspace --all-targets --all-features
npm_constants:
name: Check if node constants are up to date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Rebuild constants
run: npm run build:core:constants
- name: Check that constants are not changed
run: git diff --exit-code
cargo_deny:
name: cargo deny
runs-on: ubuntu-latest

View File

@@ -271,13 +271,18 @@ jobs:
name: Build & Publish npm prebuilds and deltachat-rpc-server
needs: ["build_linux", "build_windows", "build_macos"]
runs-on: "ubuntu-latest"
permissions:
id-token: write
# Needed to publish the binaries to the release.
contents: write
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: "3.11"
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v4
@@ -368,7 +373,7 @@ jobs:
for platform in ./platform_package/*; do npm pack "$platform"; done
npm pack
ls -lah
- name: Upload to artifacts
uses: actions/upload-artifact@v4
with:
@@ -383,11 +388,19 @@ jobs:
run: |
gh release upload ${{ github.ref_name }} \
--repo ${{ github.repository }} \
*.tgz
deltachat-rpc-server/npm-package/*.tgz
# Configure Node.js for publishing.
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
- name: Publish npm packets for prebuilds and `@deltachat/stdio-rpc-server`
if: github.event_name == 'release'
working-directory: deltachat-rpc-server/npm-package
run: |
ls -lah platform_package
for platform in *.tgz; do npm publish "$platform"; done
for platform in *.tgz; do npm publish --provenance "$platform" --access public; done
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,82 +1,38 @@
name: "jsonrpc js client build"
name: "Publish @deltachat/jsonrpc-client"
on:
pull_request:
push:
tags:
- "*"
- "!py-*"
workflow_dispatch:
release:
types: [published]
jobs:
pack-module:
name: "Package @deltachat/jsonrpc-client and upload to download.delta.chat"
name: "Publish @deltachat/jsonrpc-client"
runs-on: ubuntu-20.04
permissions:
id-token: write
contents: read
steps:
- name: Install tree
run: sudo apt install tree
- uses: actions/checkout@v4
with:
show-progress: false
- uses: actions/setup-node@v4
with:
node-version: "18"
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
continue-on-error: true
- name: Get Pull Request ID
id: prepare
run: |
tag=${{ steps.tag.outputs.tag }}
if [ -z "$tag" ]; then
node -e "console.log('DELTACHAT_JSONRPC_TAR_GZ=deltachat-jsonrpc-client-' + '${{ github.ref }}'.split('/')[2] + '.tar.gz')" >> $GITHUB_ENV
else
echo "DELTACHAT_JSONRPC_TAR_GZ=deltachat-jsonrpc-client-${{ steps.tag.outputs.tag }}.tar.gz" >> $GITHUB_ENV
echo "No preview will be uploaded this time, but the $tag release"
fi
- name: System info
run: |
npm --version
node --version
echo $DELTACHAT_JSONRPC_TAR_GZ
node-version: 20
registry-url: "https://registry.npmjs.org"
- name: Install dependencies without running scripts
working-directory: deltachat-jsonrpc/typescript
run: npm install --ignore-scripts
- name: Package
shell: bash
working-directory: deltachat-jsonrpc/typescript
run: |
npm run build
npm pack .
ls -lah
mv $(find deltachat-jsonrpc-client-*) $DELTACHAT_JSONRPC_TAR_GZ
- name: Upload Prebuild
uses: actions/upload-artifact@v4
with:
name: deltachat-jsonrpc-client.tgz
path: deltachat-jsonrpc/typescript/${{ env.DELTACHAT_JSONRPC_TAR_GZ }}
# Upload to download.delta.chat/node/preview/
- name: Upload deltachat-jsonrpc-client preview to download.delta.chat/node/preview/
if: ${{ ! steps.tag.outputs.tag }}
id: upload-preview
shell: bash
run: |
echo -e "${{ secrets.SSH_KEY }}" >__TEMP_INPUT_KEY_FILE
chmod 600 __TEMP_INPUT_KEY_FILE
scp -o StrictHostKeyChecking=no -v -i __TEMP_INPUT_KEY_FILE -P "22" -r deltachat-jsonrpc/typescript/$DELTACHAT_JSONRPC_TAR_GZ "${{ secrets.USERNAME }}"@"download.delta.chat":"/var/www/html/download/node/preview/"
continue-on-error: true
- name: Post links to details
if: steps.upload-preview.outcome == 'success'
run: node ./node/scripts/postLinksToDetails.js
- name: Publish
working-directory: deltachat-jsonrpc/typescript
run: npm publish --provenance deltachat-jsonrpc-client-* --access public
env:
URL: preview/${{ env.DELTACHAT_JSONRPC_TAR_GZ }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MSG_CONTEXT: Download the deltachat-jsonrpc-client.tgz
# Upload to download.delta.chat/node/
- name: Upload deltachat-jsonrpc-client build to download.delta.chat/node/
if: ${{ steps.tag.outputs.tag }}
id: upload
shell: bash
run: |
echo -e "${{ secrets.SSH_KEY }}" >__TEMP_INPUT_KEY_FILE
chmod 600 __TEMP_INPUT_KEY_FILE
scp -o StrictHostKeyChecking=no -v -i __TEMP_INPUT_KEY_FILE -P "22" -r deltachat-jsonrpc/typescript/$DELTACHAT_JSONRPC_TAR_GZ "${{ secrets.USERNAME }}"@"download.delta.chat":"/var/www/html/download/node/"
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View File

@@ -34,7 +34,6 @@ deltachat-ffi/xml
coverage/
.DS_Store
.vscode
.vscode/launch.json
python/accounts.txt
python/all-testaccounts.txt
tmp/
@@ -51,4 +50,4 @@ result
# direnv
.envrc
.direnv
.direnv

View File

@@ -1,5 +1,198 @@
# Changelog
## [1.139.4] - 2024-05-21
### Features / Changes
- Scale up contact origins to OutgoingTo when sending a message.
- Add import_vcard() ([#5202](https://github.com/deltachat/deltachat-core-rust/pull/5202)).
### Fixes
- Do not log warning if iroh relay metadata is NIL.
- contact-tools: Parse_vcard: Support `\r\n` newlines.
- Make_vcard: Add authname and key for ContactId::SELF.
### Other
- nix: Add nextest ([#5610](https://github.com/deltachat/deltachat-core-rust/pull/5610)).
## [1.139.3] - 2024-05-20
### API-Changes
- [**breaking**] @deltachat/stdio-rpc-server: change api: don't search in path unless `options.takeVersionFromPATH` is set to `true`
- @deltachat/stdio-rpc-server: remove `DELTA_CHAT_SKIP_PATH` environment variable
- @deltachat/stdio-rpc-server: remove version check / search for dc rpc server in $PATH
- @deltachat/stdio-rpc-server: remove `options.skipSearchInPath`
- @deltachat/stdio-rpc-server: add `options.takeVersionFromPATH`
- deltachat-rpc-client: Add Account.wait_for_incoming_msg().
### Features / Changes
- Replace env_logger with tracing_subscriber.
### Fixes
- Ignore event channel overflows.
- mimeparser: Take the last header of multiple ones with the same name.
- Db migration version 59, it contained an sql syntax error.
- Sql syntax error in db migration 27.
- Log/print exit error of deltachat-rpc-server ([#5601](https://github.com/deltachat/deltachat-core-rust/pull/5601)).
- @deltachat/stdio-rpc-server: set default options for `startDeltaChat`.
- Always convert absolute paths to relative in accounts.toml.
### Refactor
- receive_imf: Do not check for ContactId::UNDEFINED.
- receive_imf: Remove unnecessary check for is_mdn.
- receive_imf: Only call create_or_lookup_group() with allow_creation=true.
- Use let..else in create_or_lookup_group().
- Stop trying to extract chat ID from Message-IDs.
- Do not try to lookup group in create_or_lookup_group().
## [1.139.2] - 2024-05-18
### Build system
- Add repository URL to @deltachat/jsonrpc-client.
## [1.139.1] - 2024-05-18
### CI
- Set `--access public` when publishing to npm.
## [1.139.0] - 2024-05-18
### Features / Changes
- Ephemeral peer channels ([#5346](https://github.com/deltachat/deltachat-core-rust/pull/5346)).
### Fixes
- Save override sender displayname for outgoing messages.
- Do not mark the message as seen if it has `location.kml`.
- @deltachat/stdio-rpc-server: fix version check when deltachat-rpc-server is found in path ([#5579](https://github.com/deltachat/deltachat-core-rust/pull/5579)).
- @deltachat/stdio-rpc-server: fix local desktop development ([#5583](https://github.com/deltachat/deltachat-core-rust/pull/5583)).
- @deltachat/stdio-rpc-server: rename `shutdown` method to `close` and add `muteStdErr` option to mute the stderr output ([#5588](https://github.com/deltachat/deltachat-core-rust/pull/5588))
- @deltachat/stdio-rpc-server: fix `convert_platform.py`: 32bit `i32` -> `ia32` ([#5589](https://github.com/deltachat/deltachat-core-rust/pull/5589))
- @deltachat/stdio-rpc-server: fix example ([#5580](https://github.com/deltachat/deltachat-core-rust/pull/5580))
### API-Changes
- deltachat-jsonrpc: Return vcard contact directly in MessageObject.
- deltachat-jsonrpc: Add api `migrate_account` and `get_blob_dir` ([#5584](https://github.com/deltachat/deltachat-core-rust/pull/5584)).
- deltachat-rpc-client: Add ViewType.VCARD constant.
- deltachat-rpc-client: Add Contact.make_vcard().
- deltachat-rpc-client: Add Chat.send_contact().
### CI
- Publish @deltachat/jsonrpc-client directly to npm.
- Check that constants are always up-to-date.
### Build system
- nix: Add git-cliff to flake.
- nix: Use rust-analyzer nightly
### Miscellaneous Tasks
- cargo: Downgrade libc from 0.2.154 to 0.2.153.
### Tests
- deltachat-rpc-client: Test sending vCard.
## [1.138.5] - 2024-05-16
### API-Changes
- jsonrpc: Add parse_vcard() ([#5202](https://github.com/deltachat/deltachat-core-rust/pull/5202)).
- Add Viewtype::Vcard ([#5202](https://github.com/deltachat/deltachat-core-rust/pull/5202)).
- Add make_vcard() ([#5203](https://github.com/deltachat/deltachat-core-rust/pull/5203)).
### Build system
- Add repository URL to deltachat-rpc-server packages.
### Fixes
- Parsing vCards with avatars exported by Android's "Contacts" app.
### Miscellaneous Tasks
- Rebuild node constants.
### Refactor
- contact-tools: VcardContact: rename display_name to authname.
- VcardContact: Change timestamp type to i64.
## [1.138.4] - 2024-05-15
### CI
- Run actions/setup-node before npm publish.
## [1.138.3] - 2024-05-15
### CI
- Give CI job permission to publish binaries to the release.
## [1.138.2] - 2024-05-15
### API-Changes
- deltachat-rpc-client: Add CONFIG_SYNCED constant.
### CI
- Add npm token to publish deltachat-rpc-server packages.
### Features / Changes
- Reset more settings when configuring a chatmail account.
### Tests
- Set configuration after configure() finishes.
## [1.138.1] - 2024-05-14
### Features / Changes
- Detect XCHATMAIL capability and expose it as `is_chatmail` config.
### Fixes
- Never treat message with Chat-Group-ID as a private reply.
- Always prefer Chat-Group-ID over In-Reply-To and References.
- Ignore parent message if message references itself.
### CI
- Set RUSTUP_WINDOWS_PATH_ADD_BIN to work around `nextest` issue <https://github.com/nextest-rs/nextest/issues/1493>.
- deltachat-rpc-server: Fix upload of npm packages to github releases ([#5564](https://github.com/deltachat/deltachat-core-rust/pull/5564)).
### Refactor
- Add MimeMessage.get_chat_group_id().
- Make MimeMessage.get_header() return Option<&str>.
- sql: Make open flags immutable.
- Resultify token::lookup_or_new().
### Miscellaneous Tasks
- cargo: Bump parking_lot from 0.12.1 to 0.12.2.
- cargo: Bump libc from 0.2.153 to 0.2.154.
- cargo: Bump hickory-resolver from 0.24.0 to 0.24.1.
- cargo: Bump serde_json from 1.0.115 to 1.0.116.
- cargo: Bump human-panic from 1.2.3 to 2.0.0.
- cargo: Bump brotli from 5.0.0 to 6.0.0.
## [1.138.0] - 2024-05-13
### API-Changes
@@ -4072,3 +4265,13 @@ https://github.com/deltachat/deltachat-core-rust/pulls?q=is%3Apr+is%3Aclosed
[1.137.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.137.2...v1.137.3
[1.137.4]: https://github.com/deltachat/deltachat-core-rust/compare/v1.137.3...v1.137.4
[1.138.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.137.4...v1.138.0
[1.138.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.138.0...v1.138.1
[1.138.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.138.1...v1.138.2
[1.138.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.138.2...v1.138.3
[1.138.4]: https://github.com/deltachat/deltachat-core-rust/compare/v1.138.3...v1.138.4
[1.138.5]: https://github.com/deltachat/deltachat-core-rust/compare/v1.138.4...v1.138.5
[1.139.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.138.5...v1.139.0
[1.139.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.0...v1.139.1
[1.139.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.1...v1.139.2
[1.139.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.2...v1.139.3
[1.139.4]: https://github.com/deltachat/deltachat-core-rust/compare/v1.139.3...v1.139.4

1977
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "1.138.0"
version = "1.139.4"
edition = "2021"
license = "MPL-2.0"
rust-version = "1.77"
@@ -47,7 +47,7 @@ async-smtp = { version = "0.9", default-features = false, features = ["runtime-t
async_zip = { version = "0.0.12", default-features = false, features = ["deflate", "fs"] }
backtrace = "0.3"
base64 = "0.22"
brotli = { version = "5", default-features=false, features = ["std"] }
brotli = { version = "6", default-features=false, features = ["std"] }
chrono = { workspace = true }
email = { git = "https://github.com/deltachat/rust-email", branch = "master" }
encoded-words = { git = "https://github.com/async-email/encoded-words", branch = "master" }
@@ -60,7 +60,10 @@ hex = "0.4.0"
hickory-resolver = "0.24"
humansize = "2"
image = { version = "0.25.1", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh = { version = "0.4.2", default-features = false }
iroh_old = { version = "0.4.2", default-features = false, package = "iroh"}
iroh-net = "0.16.2"
iroh-gossip = { version = "0.16.2", features = ["net"] }
quinn = "0.10.0"
kamadak-exif = "0.5.3"
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
libc = "0.2"
@@ -73,13 +76,12 @@ once_cell = { workspace = true }
percent-encoding = "2.3"
parking_lot = "0.12"
pgp = { version = "0.11", default-features = false }
pretty_env_logger = { version = "0.5", optional = true }
qrcodegen = "1.7.0"
quick-xml = "0.31"
quoted_printable = "0.5"
rand = "0.8"
regex = { workspace = true }
reqwest = { version = "0.12.2", features = ["json"] }
reqwest = { version = "0.11.27", features = ["json"] }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
sanitize-filename = "0.5"
@@ -116,7 +118,6 @@ anyhow = { version = "1", features = ["backtrace"] } # Enable `backtrace` featur
criterion = { version = "0.5.1", features = ["async_tokio"] }
futures-lite = "2.3.0"
log = "0.4"
pretty_env_logger = "0.5"
proptest = { version = "1", default-features = false, features = ["std"] }
tempfile = "3"
testdir = "0.9.0"
@@ -178,4 +179,4 @@ vendored = [
"async-native-tls/vendored",
"rusqlite/bundled-sqlcipher-vendored-openssl",
"reqwest/native-tls-vendored"
]
]

View File

@@ -4,21 +4,18 @@ For example, to release version 1.116.0 of the core, do the following steps.
1. Resolve all [blocker issues](https://github.com/deltachat/deltachat-core-rust/labels/blocker).
2. Run `npm run build:core:constants` in the root of the repository
and commit generated `node/constants.js`, `node/events.js` and `node/lib/constants.js`.
2. Update the changelog: `git cliff --unreleased --tag 1.116.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.116.0 -p CHANGELOG.md`.
3. Update the changelog: `git cliff --unreleased --tag 1.116.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.116.0 -p CHANGELOG.md`.
4. add a link to compare previous with current version to the end of CHANGELOG.md:
3. add a link to compare previous with current version to the end of CHANGELOG.md:
`[1.116.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.115.2...v1.116.0`
5. Update the version by running `scripts/set_core_version.py 1.116.0`.
4. Update the version by running `scripts/set_core_version.py 1.116.0`.
6. Commit the changes as `chore(release): prepare for 1.116.0`.
5. Commit the changes as `chore(release): prepare for 1.116.0`.
Optionally, use a separate branch like `prep-1.116.0` for this commit and open a PR for review.
7. Tag the release: `git tag -a v1.116.0`.
6. Tag the release: `git tag -a v1.116.0`.
8. Push the release tag: `git push origin v1.116.0`.
7. Push the release tag: `git push origin v1.116.0`.
9. Create a GitHub release: `gh release create v1.116.0 -n ''`.
8. Create a GitHub release: `gh release create v1.116.0 -n ''`.

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::contact::Contact;
use deltachat::context::Context;

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use std::path::PathBuf;
use criterion::{black_box, criterion_group, criterion_main, Criterion};

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use std::path::Path;
use criterion::{black_box, criterion_group, criterion_main, Criterion};

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use std::path::Path;
use criterion::{black_box, criterion_group, criterion_main, Criterion};

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use std::path::PathBuf;
use criterion::{black_box, criterion_group, criterion_main, Criterion};

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use std::path::Path;
use criterion::{black_box, criterion_group, criterion_main, Criterion};

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use criterion::{criterion_group, criterion_main, Criterion};
use deltachat::context::Context;

View File

@@ -44,14 +44,25 @@ use regex::Regex;
pub struct VcardContact {
/// The email address, vcard property `email`
pub addr: String,
/// The contact's display name, vcard property `fn`
pub display_name: 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<u64>,
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.
@@ -60,7 +71,6 @@ pub struct VcardContact {
pub fn make_vcard(contacts: &[VcardContact]) -> String {
fn format_timestamp(c: &VcardContact) -> Option<String> {
let timestamp = *c.timestamp.as_ref().ok()?;
let timestamp: i64 = timestamp.try_into().ok()?;
let datetime = DateTime::from_timestamp(timestamp, 0)?;
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
}
@@ -68,10 +78,7 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
let mut res = "".to_string();
for c in contacts {
let addr = &c.addr;
let display_name = match c.display_name.is_empty() {
false => &c.display_name,
true => &c.addr,
};
let display_name = c.display_name();
res += &format!(
"BEGIN:VCARD\n\
VERSION:4.0\n\
@@ -93,7 +100,7 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
}
/// Parses `VcardContact`s from a given `&str`.
pub fn parse_vcard(vcard: &str) -> Result<Vec<VcardContact>> {
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())?;
@@ -125,7 +132,7 @@ pub fn parse_vcard(vcard: &str) -> Result<Vec<VcardContact>> {
}
Some(value)
}
fn parse_datetime(datetime: &str) -> Result<u64> {
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.
@@ -144,10 +151,14 @@ pub fn parse_vcard(vcard: &str) -> Result<Vec<VcardContact>> {
Err(_) => return Err(e.into()),
},
};
Ok(timestamp.try_into()?)
Ok(timestamp)
}
let mut lines = vcard.lines().peekable();
// 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() {
@@ -175,8 +186,11 @@ pub fn parse_vcard(vcard: &str) -> Result<Vec<VcardContact>> {
{
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);
@@ -187,11 +201,11 @@ pub fn parse_vcard(vcard: &str) -> Result<Vec<VcardContact>> {
}
}
let (display_name, addr) =
let (authname, addr) =
sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or(""));
contacts.push(VcardContact {
display_name,
authname,
addr,
key: key.map(|s| s.to_string()),
profile_image: photo.map(|s| s.to_string()),
@@ -201,7 +215,7 @@ pub fn parse_vcard(vcard: &str) -> Result<Vec<VcardContact>> {
});
}
Ok(contacts)
contacts
}
/// Valid contact address.
@@ -430,17 +444,16 @@ EMAIL;PREF=1:bobzzz@freenet.de
UID:cac4fef4-6351-4854-bbe4-9b6df857eaed
END:VCARD
",
)
.unwrap();
);
assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string());
assert_eq!(contacts[0].display_name, "Alice Mueller".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].display_name, "".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());
@@ -461,11 +474,10 @@ KEY;TYPE=PGP;ENCODING=b:[base64-data]
REV:20240418T184242Z
END:VCARD",
)
.unwrap();
);
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
assert_eq!(contacts[0].display_name, "Alice Wonderland".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);
@@ -478,27 +490,48 @@ END:VCARD",
let contacts = [
VcardContact {
addr: "alice@example.org".to_string(),
display_name: "Alice Wonderland".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(),
display_name: "".to_string(),
authname: "".to_string(),
key: None,
profile_image: None,
timestamp: Ok(0),
},
];
let items = [
"BEGIN:VCARD\n\
VERSION:4.0\n\
EMAIL:alice@example.org\n\
FN:Alice Wonderland\n\
KEY:data:application/pgp-keys;base64,[base64-data]\n\
PHOTO:data:image/jpeg;base64,image in Base64\n\
REV:20240418T184242Z\n\
END:VCARD\n",
"BEGIN:VCARD\n\
VERSION:4.0\n\
EMAIL:bob@example.com\n\
FN:bob@example.com\n\
REV:19700101T000000Z\n\
END:VCARD\n",
];
let mut expected = "".to_string();
for len in 0..=contacts.len() {
let contacts = &contacts[0..len];
let vcard = make_vcard(contacts);
let parsed = parse_vcard(&vcard).unwrap();
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].display_name, contacts[i].display_name);
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!(
@@ -572,16 +605,15 @@ FN:Alice
EMAIL;HOME:alice@example.org
END:VCARD
",
)
.unwrap();
);
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
assert_eq!(contacts[0].display_name, "Bob".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].display_name, "Alice".to_string());
assert_eq!(contacts[1].authname, "Alice".to_string());
assert_eq!(contacts[1].key, None);
assert_eq!(contacts[1].profile_image, None);
@@ -597,19 +629,43 @@ END:VCARD
EMAIL;TYPE=work:alice@example.org\n\
REV:20240418T184242\n\
END:VCARD",
)
.unwrap();
);
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].addr, "alice@example.org".to_string());
assert_eq!(contacts[0].display_name, "Alice Wonderland".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()
.try_into()
.unwrap()
);
}
#[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==");
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "1.138.0"
version = "1.139.4"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"
@@ -17,7 +17,7 @@ crate-type = ["cdylib", "staticlib"]
deltachat = { path = "../", default-features = false }
deltachat-jsonrpc = { path = "../deltachat-jsonrpc", optional = true }
libc = "0.2"
human-panic = { version = "1", default-features = false }
human-panic = { version = "2", default-features = false }
num-traits = "0.2"
serde_json = "1.0"
tokio = { version = "1.37.0", features = ["rt-multi-thread"] }

View File

@@ -517,6 +517,7 @@ char* dc_get_blobdir (const dc_context_t* context);
* 0=Nothing else happens when the key changes.
* 1=After the key changed, `dc_chat_can_send()` returns false and `dc_chat_is_protection_broken()` returns true
* until `dc_accept_chat()` is called.
* - `is_chatmail` = 1 if the the server is a chatmail server, 0 otherwise.
* - `ui.*` = All keys prefixed by `ui.` can be used by the user-interfaces for system-specific purposes.
* The prefix should be followed by the system and maybe subsystem,
* e.g. `ui.desktop.foo`, `ui.desktop.linux.bar`, `ui.android.foo`, `ui.dc40.bar`, `ui.bot.simplebot.baz`.
@@ -5479,6 +5480,11 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
*/
#define DC_MSG_WEBXDC 80
/**
* Message containing shared contacts represented as a vCard (virtual contact file)
* with email addresses and possibly other fields.
*/
#define DC_MSG_VCARD 90
/**
* @}
@@ -7337,6 +7343,9 @@ void dc_event_unref(dc_event_t* event);
/// Used as info message.
#define DC_STR_SECUREJOIN_WAIT_TIMEOUT 191
/// "Contact"
#define DC_STR_CONTACT 200
/**
* @}
*/

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
#![warn(unused, clippy::all)]
#![allow(
non_camel_case_types,
@@ -561,6 +562,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::ConfigSynced { .. } => 2111,
EventType::WebxdcStatusUpdate { .. } => 2120,
EventType::WebxdcInstanceDeleted { .. } => 2121,
EventType::WebxdcRealtimeData { .. } => 2150,
EventType::AccountsBackgroundFetchDone => 2200,
EventType::ChatlistChanged => 2300,
EventType::ChatlistItemChanged { .. } => 2301,
@@ -616,8 +618,9 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
| EventType::SecurejoinJoinerProgress { contact_id, .. } => {
contact_id.to_u32() as libc::c_int
}
EventType::WebxdcStatusUpdate { msg_id, .. } => msg_id.to_u32() as libc::c_int,
EventType::WebxdcInstanceDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int,
EventType::WebxdcRealtimeData { msg_id, .. }
| EventType::WebxdcStatusUpdate { msg_id, .. }
| EventType::WebxdcInstanceDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int,
EventType::ChatlistItemChanged { chat_id } => {
chat_id.unwrap_or_default().to_u32() as libc::c_int
}
@@ -655,6 +658,7 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ConnectivityChanged
| EventType::WebxdcInstanceDeleted { .. }
| EventType::IncomingMsgBunch { .. }
| EventType::WebxdcRealtimeData { .. }
| EventType::SelfavatarChanged
| EventType::AccountsBackgroundFetchDone
| EventType::ChatlistChanged
@@ -721,6 +725,7 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::SelfavatarChanged
| EventType::WebxdcStatusUpdate { .. }
| EventType::WebxdcInstanceDeleted { .. }
| EventType::WebxdcRealtimeData { .. }
| EventType::AccountsBackgroundFetchDone
| EventType::ChatEphemeralTimerModified { .. }
| EventType::IncomingMsgBunch { .. }

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "1.138.0"
version = "1.139.4"
description = "DeltaChat JSON-RPC API"
edition = "2021"
default-run = "deltachat-jsonrpc-server"
@@ -15,6 +15,7 @@ required-features = ["webserver"]
[dependencies]
anyhow = "1"
deltachat = { path = ".." }
deltachat-contact-tools = { path = "../deltachat-contact-tools" }
num-traits = "0.2"
schemars = "0.8.19"
serde = { version = "1.0", features = ["derive"] }

View File

@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use std::path::Path;
use std::str;
use std::sync::Arc;
use std::time::Duration;
use std::{collections::HashMap, str::FromStr};
@@ -17,12 +18,14 @@ use deltachat::constants::DC_MSG_ID_DAYMARKER;
use deltachat::contact::{may_be_valid_addr, Contact, ContactId, Origin};
use deltachat::context::get_info;
use deltachat::ephemeral::Timer;
use deltachat::imex;
use deltachat::location;
use deltachat::message::get_msg_read_receipts;
use deltachat::message::{
self, delete_msgs, markseen_msgs, Message, MessageState, MsgId, Viewtype,
};
use deltachat::peer_channels::{
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
};
use deltachat::provider::get_provider_info;
use deltachat::qr::{self, Qr};
use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
@@ -31,6 +34,7 @@ use deltachat::securejoin;
use deltachat::stock_str::StockMessage;
use deltachat::webxdc::StatusUpdateSerial;
use deltachat::EventEmitter;
use deltachat::{imex, info};
use sanitize_filename::is_sanitized;
use tokio::fs;
use tokio::sync::{watch, Mutex, RwLock};
@@ -42,7 +46,7 @@ pub mod types;
use num_traits::FromPrimitive;
use types::account::Account;
use types::chat::FullChat;
use types::contact::ContactObject;
use types::contact::{ContactObject, VcardContact};
use types::events::Event;
use types::http::HttpResponse;
use types::message::{MessageData, MessageObject, MessageReadReceipt};
@@ -184,6 +188,16 @@ impl CommandApi {
self.accounts.write().await.add_account().await
}
/// Imports/migrated an existing account from a database path into this account manager.
/// Returns the ID of new account.
async fn migrate_account(&self, path_to_db: String) -> Result<u32> {
self.accounts
.write()
.await
.migrate_account(std::path::PathBuf::from(path_to_db))
.await
}
async fn remove_account(&self, account_id: u32) -> Result<()> {
self.accounts
.write()
@@ -328,6 +342,11 @@ impl CommandApi {
ctx.get_info().await
}
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()))
}
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())
@@ -1426,6 +1445,37 @@ impl CommandApi {
Ok(contact_id.map(|id| id.to_u32()))
}
/// Parses a vCard file located at the given path. Returns contacts in their original order.
async fn parse_vcard(&self, path: String) -> Result<Vec<VcardContact>> {
let vcard = fs::read(Path::new(&path)).await?;
let vcard = str::from_utf8(&vcard)?;
Ok(deltachat_contact_tools::parse_vcard(vcard)
.into_iter()
.map(|c| c.into())
.collect())
}
/// Imports contacts from a vCard file located at the given path.
///
/// Returns the ids of created/modified contacts in the order they appear in the vCard.
async fn import_vcard(&self, account_id: u32, path: String) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
let vcard = tokio::fs::read(Path::new(&path)).await?;
let vcard = str::from_utf8(&vcard)?;
Ok(deltachat::contact::import_vcard(&ctx, vcard)
.await?
.into_iter()
.map(|c| c.to_u32())
.collect())
}
/// Returns a vCard containing contacts with the given ids.
async fn make_vcard(&self, account_id: u32, contacts: Vec<u32>) -> Result<String> {
let ctx = self.get_context(account_id).await?;
let contacts: Vec<_> = contacts.iter().map(|&c| ContactId::new(c)).collect();
deltachat::contact::make_vcard(&ctx, &contacts).await
}
// ---------------------------------------------
// chat
// ---------------------------------------------
@@ -1730,6 +1780,37 @@ impl CommandApi {
.await
}
async fn send_webxdc_realtime_data(
&self,
account_id: u32,
instance_msg_id: u32,
data: Vec<u8>,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
send_webxdc_realtime_data(&ctx, MsgId::new(instance_msg_id), data).await
}
async fn send_webxdc_realtime_advertisement(
&self,
account_id: u32,
instance_msg_id: u32,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let fut = send_webxdc_realtime_advertisement(&ctx, MsgId::new(instance_msg_id)).await?;
if let Some(fut) = fut {
tokio::spawn(async move {
fut.await.ok();
info!(ctx, "send_webxdc_realtime_advertisement done")
});
}
Ok(())
}
async fn leave_webxdc_realtime(&self, account_id: u32, instance_message_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
leave_webxdc_realtime(&ctx, MsgId::new(instance_message_id)).await
}
async fn get_webxdc_status_updates(
&self,
account_id: u32,

View File

@@ -1,4 +1,5 @@
use anyhow::Result;
use deltachat::color;
use deltachat::context::Context;
use serde::Serialize;
use typescript_type_def::TypeDef;
@@ -87,3 +88,35 @@ impl ContactObject {
})
}
}
#[derive(Clone, Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct VcardContact {
/// Email address.
addr: String,
/// The contact's name, or the email address if no name was given.
display_name: String,
/// Public PGP key in Base64.
key: Option<String>,
/// Profile image in Base64.
profile_image: Option<String>,
/// Contact color as hex string.
color: String,
/// Last update timestamp.
timestamp: Option<i64>,
}
impl From<deltachat_contact_tools::VcardContact> for VcardContact {
fn from(vc: deltachat_contact_tools::VcardContact) -> Self {
let display_name = vc.display_name().to_string();
let color = color::str_to_color(&vc.addr.to_lowercase());
Self {
addr: vc.addr,
display_name,
key: vc.key,
profile_image: vc.profile_image,
color: color_int_to_hex_string(color),
timestamp: vc.timestamp.ok(),
}
}
}

View File

@@ -240,6 +240,10 @@ pub enum EventType {
status_update_serial: u32,
},
/// Data received over an ephemeral peer channel.
#[serde(rename_all = "camelCase")]
WebxdcRealtimeData { msg_id: u32, data: Vec<u8> },
/// Inform that a message containing a webxdc instance has been deleted
#[serde(rename_all = "camelCase")]
WebxdcInstanceDeleted { msg_id: u32 },
@@ -362,6 +366,10 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(),
status_update_serial: status_update_serial.to_u32(),
},
CoreEventType::WebxdcRealtimeData { msg_id, data } => WebxdcRealtimeData {
msg_id: msg_id.to_u32(),
data,
},
CoreEventType::WebxdcInstanceDeleted { msg_id } => WebxdcInstanceDeleted {
msg_id: msg_id.to_u32(),
},

View File

@@ -1,3 +1,4 @@
use crate::api::VcardContact;
use anyhow::{Context as _, Result};
use deltachat::chat::Chat;
use deltachat::chat::ChatItem;
@@ -87,6 +88,8 @@ pub struct MessageObject {
download_state: DownloadState,
reactions: Option<JSONRPCReactions>,
vcard_contact: Option<VcardContact>,
}
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
@@ -173,6 +176,13 @@ impl MessageObject {
Some(reactions.into())
};
let vcard_contacts: Vec<VcardContact> = message
.vcard_contacts(context)
.await?
.into_iter()
.map(Into::into)
.collect();
Ok(MessageObject {
id: msg_id.to_u32(),
chat_id: message.get_chat_id().to_u32(),
@@ -232,6 +242,8 @@ impl MessageObject {
download_state,
reactions,
vcard_contact: vcard_contacts.first().cloned(),
})
}
}
@@ -274,6 +286,11 @@ pub enum MessageViewtype {
/// Message is an webxdc instance.
Webxdc,
/// Message containing shared contacts represented as a vCard (virtual contact file)
/// with email addresses and possibly other fields.
/// Use `parse_vcard()` to retrieve them.
Vcard,
}
impl From<Viewtype> for MessageViewtype {
@@ -290,6 +307,7 @@ impl From<Viewtype> for MessageViewtype {
Viewtype::File => MessageViewtype::File,
Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation,
Viewtype::Webxdc => MessageViewtype::Webxdc,
Viewtype::Vcard => MessageViewtype::Vcard,
}
}
}
@@ -308,6 +326,7 @@ impl From<MessageViewtype> for Viewtype {
MessageViewtype::File => Viewtype::File,
MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation,
MessageViewtype::Webxdc => Viewtype::Webxdc,
MessageViewtype::Vcard => Viewtype::Vcard,
}
}
}
@@ -372,6 +391,9 @@ pub enum SystemMessageType {
/// Webxdc info added with `info` set in `send_webxdc_status_update()`.
WebxdcInfoMessage,
/// This message contains a users iroh node address.
IrohNodeAddr,
}
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -394,6 +416,7 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::WebxdcStatusUpdate => SystemMessageType::WebxdcStatusUpdate,
SystemMessage::WebxdcInfoMessage => SystemMessageType::WebxdcInfoMessage,
SystemMessage::InvalidUnencryptedMail => SystemMessageType::InvalidUnencryptedMail,
SystemMessage::IrohNodeAddr => SystemMessageType::IrohNodeAddr,
SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait,
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
}

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
pub mod api;
pub use yerpc;

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use std::net::SocketAddr;
use std::path::PathBuf;

View File

@@ -32,6 +32,10 @@
"license": "MPL-2.0",
"main": "dist/deltachat.js",
"name": "@deltachat/jsonrpc-client",
"repository": {
"type": "git",
"url": "https://github.com/deltachat/deltachat-core-rust.git"
},
"scripts": {
"build": "run-s generate-bindings extract-constants build:tsc build:bundle build:cjs",
"build:bundle": "esbuild --format=esm --bundle dist/deltachat.js --outfile=dist/deltachat.bundle.js",
@@ -54,5 +58,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "1.138.0"
"version": "1.139.4"
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "1.138.0"
version = "1.139.4"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/deltachat/deltachat-core-rust"
@@ -11,10 +11,10 @@ anyhow = "1"
deltachat = { path = "..", features = ["internals"]}
dirs = "5"
log = "0.4.21"
pretty_env_logger = "0.5"
rusqlite = "0.31"
rustyline = "14"
tokio = { version = "1.37.0", features = ["fs", "rt-multi-thread", "macros"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[features]
default = ["vendored"]

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
//! This is a CLI program and a little testing frame. This file must not be
//! included when using Delta Chat Core as a library.
//!
@@ -31,6 +32,7 @@ use rustyline::{
};
use tokio::fs;
use tokio::runtime::Handle;
use tracing_subscriber::EnvFilter;
mod cmdline;
use self::cmdline::*;
@@ -482,9 +484,10 @@ async fn handle_cmd(
#[tokio::main]
async fn main() -> Result<(), Error> {
pretty_env_logger::formatted_timed_builder()
.parse_default_env()
.filter_module("deltachat_repl", log::LevelFilter::Info)
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::from_default_env().add_directive("deltachat_repl=info".parse()?),
)
.init();
let args = std::env::args().collect();

View File

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

View File

@@ -297,6 +297,12 @@ class Account:
if event.kind == EventType.INCOMING_MSG:
return event
def wait_for_incoming_msg(self):
"""Wait for incoming message and return it.
Consumes all events before the next incoming message event."""
return self.get_message_by_id(self.wait_for_incoming_msg_event().msg_id)
def wait_for_securejoin_inviter_success(self):
while True:
event = self.wait_for_event()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import calendar
from dataclasses import dataclass
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, Optional, Union
from ._utils import AttrDict
@@ -265,3 +266,11 @@ class Chat:
location["message"] = Message(self.account, location.msg_id)
locations.append(location)
return locations
def send_contact(self, contact: Contact):
"""Send contact to the chat."""
vcard = contact.make_vcard()
with NamedTemporaryFile(suffix=".vcard") as f:
f.write(vcard.encode())
f.flush()
self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name})

View File

@@ -61,6 +61,7 @@ class EventType(str, Enum):
WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted"
CHATLIST_CHANGED = "ChatlistChanged"
CHATLIST_ITEM_CHANGED = "ChatlistItemChanged"
CONFIG_SYNCED = "ConfigSynced"
class ChatId(IntEnum):
@@ -113,6 +114,7 @@ class ViewType(str, Enum):
FILE = "File"
VIDEOCHAT_INVITATION = "VideochatInvitation"
WEBXDC = "Webxdc"
VCARD = "Vcard"
class SystemMessageType(str, Enum):

View File

@@ -60,3 +60,6 @@ class Contact:
self.account,
self._rpc.create_chat_by_contact_id(self.account.id, self.id),
)
def make_vcard(self) -> str:
return self._rpc.make_vcard(self.account.id, [self.id])

View File

@@ -126,8 +126,7 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
alice.set_config("download_limit", "1")
event = bob.wait_for_incoming_msg_event()
msg = bob.get_message_by_id(event.msg_id)
msg = bob.wait_for_incoming_msg()
chat_id = msg.get_snapshot().chat_id
msg.get_snapshot().chat.accept()
bob.get_chat_by_id(chat_id).send_message(
@@ -135,13 +134,15 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
html=base64.b64encode(os.urandom(300000)).decode("utf-8"),
)
msg_id = alice.wait_for_incoming_msg_event().msg_id
assert alice.get_message_by_id(msg_id).get_snapshot().download_state == const.DownloadState.AVAILABLE
message = alice.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.download_state == const.DownloadState.AVAILABLE
alice.clear_all_events()
chat_id = alice.get_message_by_id(msg_id).get_snapshot().chat_id
alice._rpc.download_full_message(alice.id, msg_id)
snapshot = message.get_snapshot()
chat_id = snapshot.chat_id
alice._rpc.download_full_message(alice.id, message.id)
wait_for_chatlist_specific_item(alice, chat_id)
@@ -177,8 +178,7 @@ def test_imap_sync_seen_msgs(acfactory: ACFactory) -> None:
alice_chat_bob.send_text("hello")
event = bob.wait_for_incoming_msg_event()
msg = bob.get_message_by_id(event.msg_id)
msg = bob.wait_for_incoming_msg()
bob_chat_id = msg.get_snapshot().chat_id
msg.get_snapshot().chat.accept()
@@ -189,8 +189,7 @@ def test_imap_sync_seen_msgs(acfactory: ACFactory) -> None:
# make sure alice_second_device already received the message
alice_second_device.wait_for_incoming_msg_event()
event = alice.wait_for_incoming_msg_event()
msg = alice.get_message_by_id(event.msg_id)
msg = alice.wait_for_incoming_msg()
alice_second_device.clear_all_events()
msg.mark_seen()

View File

@@ -508,8 +508,8 @@ def test_reactions_for_a_reordering_move(acfactory):
"""
(ac1,) = acfactory.get_online_accounts(1)
ac2 = acfactory.new_preconfigured_account()
ac2.set_config("mvbox_move", "1")
ac2.configure()
ac2.set_config("mvbox_move", "1")
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()

View File

@@ -0,0 +1,15 @@
def test_vcard(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
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()
alice_chat_bob.send_contact(alice_contact_charlie)
event = bob.wait_for_incoming_msg_event()
message = bob.get_message_by_id(event.msg_id)
snapshot = message.get_snapshot()
assert snapshot.vcard_contact
assert snapshot.vcard_contact.addr == "charlie@example.org"

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "1.138.0"
version = "1.139.4"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"
@@ -14,13 +14,13 @@ deltachat-jsonrpc = { path = "../deltachat-jsonrpc", default-features = false }
deltachat = { path = "..", default-features = false }
anyhow = "1"
env_logger = { version = "0.11.3" }
futures-lite = "2.3.0"
log = "0.4"
serde_json = "1"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.37.0", features = ["io-std"] }
tokio-util = "0.7.9"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
yerpc = { version = "0.5.2", features = ["anyhow_expose", "openrpc"] }
[features]

View File

@@ -1,2 +1,3 @@
platform_package
*.tgz
*.tgz
package-lock.json

View File

@@ -20,7 +20,9 @@ import { C } from "@deltachat/jsonrpc-client";
async function main() {
const dc = await startDeltaChat("deltachat-data");
console.log(await dc.rpc.getSystemInfo());
dc.close()
}
main()
```
For a more complete example refer to https://github.com/deltachat-bot/echo/pull/69/files (TODO change link when pr is merged).
@@ -44,12 +46,11 @@ references:
When you import this package it searches for the rpc server in the following locations and order:
1. `DELTA_CHAT_RPC_SERVER` environment variable
2. in PATH
- unless `DELTA_CHAT_SKIP_PATH=1` is specified
- searches in .cargo/bin directory first
- but there an additional version check is performed
2. use the PATH when `{takeVersionFromPATH: true}` is supplied in the options.
3. prebuilds in npm packages
so by default it uses the prebuilds.
## How do you built this package in CI
- To build platform packages, run the `build_platform_package.py` script:

View File

@@ -1,8 +1,8 @@
import { StdioDeltaChat } from "@deltachat/jsonrpc-client";
export interface SearchOptions {
/** whether to disable looking for deltachat-rpc-server inside of $PATH */
skipSearchInPath: boolean;
/** whether take deltachat-rpc-server inside of $PATH*/
takeVersionFromPATH: boolean;
/** whether to disable the DELTA_CHAT_RPC_SERVER environment variable */
disableEnvPath: boolean;
@@ -20,17 +20,20 @@ export function getRPCServerPath(
export type DeltaChatOverJsonRpcServer = StdioDeltaChat & {
shutdown: () => Promise<void>;
readonly pathToServerBinary: string;
};
export interface StartOptions {
/** whether to disable outputting stderr to the parent process's stderr */
muteStdErr: boolean;
}
/**
*
* @param directory directory for accounts folder
* @param options
*/
export function startDeltaChat(directory: string, options?: Partial<SearchOptions> ): Promise<DeltaChatOverJsonRpcServer>
export function startDeltaChat(directory: string, options?: Partial<SearchOptions & StartOptions> ): Promise<DeltaChatOverJsonRpcServer>
export namespace FnTypes {

View File

@@ -1,15 +1,9 @@
//@ts-check
import { execFile, spawn } from "node:child_process";
import { stat, readdir } from "node:fs/promises";
import { spawn } from "node:child_process";
import { stat } from "node:fs/promises";
import os from "node:os";
import { join, basename } from "node:path";
import process from "node:process";
import { promisify } from "node:util";
import {
ENV_VAR_NAME,
PATH_EXECUTABLE_NAME,
SKIP_SEARCH_IN_PATH,
} from "./src/const.js";
import { ENV_VAR_NAME, PATH_EXECUTABLE_NAME } from "./src/const.js";
import {
ENV_VAR_LOCATION_NOT_FOUND,
FAILED_TO_START_SERVER_EXECUTABLE,
@@ -22,10 +16,6 @@ import {
import package_json from "./package.json" with { type: "json" };
import { createRequire } from "node:module";
// exports
// - [ ] a raw starter that has a stdin/out handle thingie like desktop uses
// - [X] a function that already wraps the stdio handle from above into the deltachat jsonrpc bindings
function findRPCServerInNodeModules() {
const arch = os.arch();
const operating_system = process.platform;
@@ -44,11 +34,12 @@ function findRPCServerInNodeModules() {
}
/** @type {import("./index").FnTypes.getRPCServerPath} */
export async function getRPCServerPath(
options = { skipSearchInPath: false, disableEnvPath: false }
) {
// @TODO: improve confusing naming of these options
const { skipSearchInPath, disableEnvPath } = options;
export async function getRPCServerPath(options = {}) {
const { takeVersionFromPATH, disableEnvPath } = {
takeVersionFromPATH: false,
disableEnvPath: false,
...options,
};
// 1. check if it is set as env var
if (process.env[ENV_VAR_NAME] && !disableEnvPath) {
try {
@@ -63,36 +54,9 @@ export async function getRPCServerPath(
return process.env[ENV_VAR_NAME];
}
// 2. check if it can be found in PATH
if (!process.env[SKIP_SEARCH_IN_PATH] && !skipSearchInPath) {
const exec = promisify(execFile);
const { stdout: executable } =
os.platform() !== "win32"
? await exec("command", ["-v", PATH_EXECUTABLE_NAME])
: await exec("where", [PATH_EXECUTABLE_NAME]);
// by just trying to execute it and then use "command -v deltachat-rpc-server" (unix) or "where deltachat-rpc-server" (windows) to get the path to the executable
if (executable.length > 1) {
// test if it is the right version
try {
// for some unknown reason it is in stderr and not in stdout
const { stderr } = await promisify(execFile)(executable, ["--version"]);
const version = stderr.slice(0, stderr.indexOf("\n"));
if (package_json.version !== version) {
throw new Error(
`version mismatch: (npm package: ${package_json.version}) (installed ${PATH_EXECUTABLE_NAME} version: ${version})`
);
} else {
return executable;
}
} catch (error) {
console.error(
"Found executable in PATH, but there was an error: " + error
);
console.error("So falling back to using prebuild...");
}
}
// 2. check if PATH should be used
if (takeVersionFromPATH) {
return PATH_EXECUTABLE_NAME;
}
// 3. check for prebuilds
@@ -102,13 +66,14 @@ export async function getRPCServerPath(
import { StdioDeltaChat } from "@deltachat/jsonrpc-client";
/** @type {import("./index").FnTypes.startDeltaChat} */
export async function startDeltaChat(directory, options) {
export async function startDeltaChat(directory, options = {}) {
const pathToServerBinary = await getRPCServerPath(options);
const server = spawn(pathToServerBinary, {
env: {
RUST_LOG: process.env.RUST_LOG || "info",
DC_ACCOUNTS_PATH: directory,
},
stdio: ["pipe", "pipe", options.muteStdErr ? "ignore" : "inherit"],
});
server.on("error", (err) => {
@@ -123,13 +88,11 @@ export async function startDeltaChat(directory, options) {
throw new Error("Server quit");
});
server.stderr.pipe(process.stderr);
/** @type {import('./index').DeltaChatOverJsonRpcServer} */
//@ts-expect-error
const dc = new StdioDeltaChat(server.stdin, server.stdout, true);
dc.shutdown = async () => {
dc.close = () => {
shouldClose = true;
if (!server.kill()) {
console.log("server termination failed");

View File

@@ -6,10 +6,14 @@
"peerDependencies": {
"@deltachat/jsonrpc-client": "*"
},
"repository": {
"type": "git",
"url": "https://github.com/deltachat/deltachat-core-rust.git"
},
"scripts": {
"prepack": "node scripts/update_optional_dependencies_and_version.js"
},
"type": "module",
"types": "index.d.ts",
"version": "1.138.0"
"version": "1.139.4"
}

View File

@@ -29,3 +29,6 @@ subprocess.run(["python", "scripts/build_platform_package.py", host_target], cap
# run update_optional_dependencies_and_version.js to adjust the package / make it installable locally
subprocess.run(["node", "scripts/update_optional_dependencies_and_version.js", "--local"], capture_output=False, check=True)
# typescript / npm local package installing/linking needs that this package has it's own node_modules folder
subprocess.run(["npm", "i"], capture_output=False, check=True)

View File

@@ -2,7 +2,7 @@ def convert_cpu_arch_to_npm_cpu_arch(arch):
if arch == "x86_64":
return "x64"
if arch == "i686":
return "i32"
return "ia32"
if arch == "aarch64":
return "arm64"
if arch == "armv7" or arch == "arm":

View File

@@ -13,14 +13,21 @@ def write_package_json(platform_path, rust_target, my_binary_name):
tomlfile = open("../../Cargo.toml", 'rb')
version = tomllib.load(tomlfile)['package']['version']
package_json = dict({
"name": "@deltachat/stdio-rpc-server-" + convert_os_to_npm_os(os) + "-" + convert_cpu_arch_to_npm_cpu_arch(cpu_arch),
package_json = {
"name": "@deltachat/stdio-rpc-server-"
+ convert_os_to_npm_os(os)
+ "-"
+ convert_cpu_arch_to_npm_cpu_arch(cpu_arch),
"version": version,
"os": [convert_os_to_npm_os(os)],
"cpu": [convert_cpu_arch_to_npm_cpu_arch(cpu_arch)],
"main": my_binary_name,
"license": "MPL-2.0"
})
"license": "MPL-2.0",
"repository": {
"type": "git",
"url": "https://github.com/deltachat/deltachat-core-rust.git",
},
}
file = open(platform_path + "/package.json", 'w')
file.write(json.dumps(package_json, indent=4))

View File

@@ -54,4 +54,10 @@ for (const { folder_name, package_name } of platform_package_names) {
: version;
}
if (is_local) {
package_json.peerDependencies["@deltachat/jsonrpc-client"] = 'file:../../deltachat-jsonrpc/typescript'
} else {
package_json.peerDependencies["@deltachat/jsonrpc-client"] = "*"
}
await fs.writeFile("./package.json", JSON.stringify(package_json, null, 4));

View File

@@ -2,5 +2,4 @@
export const PATH_EXECUTABLE_NAME = 'deltachat-rpc-server'
export const ENV_VAR_NAME = "DELTA_CHAT_RPC_SERVER"
export const SKIP_SEARCH_IN_PATH = "DELTA_CHAT_SKIP_PATH"
export const ENV_VAR_NAME = "DELTA_CHAT_RPC_SERVER"

View File

@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
//! Delta Chat core RPC server.
//!
//! It speaks JSON Lines over stdio.
@@ -10,6 +11,7 @@ use deltachat::constants::DC_VERSION_STR;
use deltachat_jsonrpc::api::{Accounts, CommandApi};
use futures_lite::stream::StreamExt;
use tokio::io::{self, AsyncBufReadExt, BufReader};
use tracing_subscriber::EnvFilter;
use yerpc::RpcServer as _;
#[cfg(target_family = "unix")]
@@ -27,6 +29,9 @@ async fn main() {
// "For technical reasons, stdin is implemented by using an ordinary blocking read on a separate
// 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!("Fatal error: {error:#}.")
}
std::process::exit(if r.is_ok() { 0 } else { 1 });
}
@@ -59,7 +64,13 @@ async fn main_impl() -> Result<()> {
#[cfg(target_family = "unix")]
let mut sigterm = signal_unix::signal(signal_unix::SignalKind::terminate())?;
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
// Logs from `log` crate and traces from `tracing` crate
// are configurable with `RUST_LOG` environment variable
// and go to stderr to avoid interferring with JSON-RPC using stdout.
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let path = std::env::var("DC_ACCOUNTS_PATH").unwrap_or_else(|_| "accounts".to_string());
log::info!("Starting with accounts directory `{}`.", path);

View File

@@ -23,6 +23,9 @@ ignore = [
# when upgrading.
# Please keep this list alphabetically sorted.
skip = [
{ name = "asn1-rs-derive", version = "0.4.0" },
{ name = "asn1-rs-impl", version = "0.1.0" },
{ name = "asn1-rs", version = "0.5.2" },
{ name = "async-channel", version = "1.9.0" },
{ name = "base16ct", version = "0.1.1" },
{ name = "base64", version = "<0.21" },
@@ -34,20 +37,37 @@ skip = [
{ name = "darling_core", version = "<0.14" },
{ name = "darling_macro", version = "<0.14" },
{ name = "darling", version = "<0.14" },
{ name = "der_derive", version = "0.6.1" },
{ name = "derive_more", version = "0.99.17" },
{ name = "der-parser", version = "8.2.0" },
{ name = "der", version = "0.6.1" },
{ name = "digest", version = "<0.10" },
{ name = "dlopen2", version = "0.4.1" },
{ name = "ed25519-dalek", version = "1.0.1" },
{ name = "ed25519", version = "1.5.3" },
{ name = "env_logger", version = "0.10.2" },
{ name = "event-listener", version = "2.5.3" },
{ name = "event-listener", version = "4.0.3" },
{ name = "fastrand", version = "1.9.0" },
{ name = "futures-lite", version = "1.13.0" },
{ name = "getrandom", version = "<0.2" },
{ name = "http-body", version = "0.4.6" },
{ name = "http", version = "0.2.12" },
{ name = "hyper", version = "0.14.28" },
{ name = "idna", version = "0.4.0" },
{ name = "netlink-packet-core", version = "0.5.0" },
{ name = "netlink-packet-route", version = "0.15.0" },
{ name = "nix", version = "0.26.4" },
{ name = "oid-registry", version = "0.6.1" },
{ name = "pem-rfc7468", version = "0.6.0" },
{ name = "pem", version = "1.1.1" },
{ name = "pkcs8", version = "0.9.0" },
{ name = "proc-macro-error-attr", version = "0.4.12" },
{ name = "proc-macro-error", version = "0.4.12" },
{ name = "quick-error", version = "<2.0" },
{ name = "rand_chacha", version = "<0.3" },
{ name = "rand_core", version = "<0.6" },
{ name = "rand", version = "<0.8" },
{ name = "rcgen", version = "<0.12.1" },
{ name = "redox_syscall", version = "0.3.5" },
{ name = "regex-automata", version = "0.1.10" },
{ name = "regex-syntax", version = "0.6.29" },
@@ -57,23 +77,31 @@ skip = [
{ name = "signature", version = "1.6.4" },
{ name = "spin", version = "<0.9.6" },
{ name = "spki", version = "0.6.0" },
{ name = "ssh-encoding", version = "0.1.0" },
{ name = "ssh-key", version = "0.5.1" },
{ name = "sync_wrapper", version = "0.1.2" },
{ name = "synstructure", version = "0.12.6" },
{ name = "syn", version = "1.0.109" },
{ name = "system-configuration-sys", version = "0.5.0" },
{ name = "system-configuration", version = "0.5.1" },
{ name = "time", version = "<0.3" },
{ name = "toml_edit", version = "0.21.1" },
{ name = "untrusted", version = "0.7.1" },
{ name = "wasi", version = "<0.11" },
{ name = "windows_aarch64_gnullvm", version = "<0.52" },
{ name = "windows_aarch64_msvc", version = "<0.52" },
{ name = "windows-core", version = "<0.54.0" },
{ name = "windows_i686_gnu", version = "<0.52" },
{ name = "windows_i686_msvc", version = "<0.52" },
{ name = "windows-sys", version = "<0.52" },
{ name = "windows-targets", version = "<0.52" },
{ name = "windows", version = "0.32.0" },
{ name = "windows", version = "<0.54.0" },
{ name = "windows_x86_64_gnullvm", version = "<0.52" },
{ name = "windows_x86_64_gnu", version = "<0.52" },
{ name = "windows_x86_64_msvc", version = "<0.52" },
{ name = "winnow", version = "0.5.40" },
{ name = "x509-parser", version = "<0.16.0" },
]

35
flake.lock generated
View File

@@ -48,11 +48,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1713421495,
"narHash": "sha256-5vVF9W1tJT+WdfpWAEG76KywktKDAW/71mVmNHEHjac=",
"lastModified": 1714112748,
"narHash": "sha256-jq6Cpf/pQH85p+uTwPPrGG8Ky/zUOTwMJ7mcqc5M4So=",
"owner": "nix-community",
"repo": "fenix",
"rev": "fd47b1f9404fae02a4f38bd9f4b12bad7833c96b",
"rev": "3ae4b908a795b6a3824d401a0702e11a7157d7e1",
"type": "github"
},
"original": {
@@ -166,11 +166,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1713248628,
"narHash": "sha256-NLznXB5AOnniUtZsyy/aPWOk8ussTuePp2acb9U+ISA=",
"lastModified": 1713895582,
"narHash": "sha256-cfh1hi+6muQMbi9acOlju3V1gl8BEaZBXBR9jQfQi4U=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5672bc9dbf9d88246ddab5ac454e82318d094bb8",
"rev": "572af610f6151fd41c212f897c71f7056e3fb518",
"type": "github"
},
"original": {
@@ -182,12 +182,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1713562564,
"narHash": "sha256-NQpYhgoy0M89g9whRixSwsHb8RFIbwlxeYiVSDwSXJg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "92d295f588631b0db2da509f381b4fb1e74173c5",
"type": "github"
"lastModified": 1711668574,
"narHash": "sha256-u1dfs0ASQIEr1icTVrsKwg2xToIpn7ZXxW3RHfHxshg=",
"path": "/nix/store/9fpv0kjq9a80isa1wkkvrdqsh9dpcn05-source",
"rev": "219951b495fc2eac67b1456824cc1ec1fd2ee659",
"type": "path"
},
"original": {
"id": "nixpkgs",
@@ -196,11 +195,11 @@
},
"nixpkgs_4": {
"locked": {
"lastModified": 1713537308,
"narHash": "sha256-XtTSSIB2DA6tOv+l0FhvfDMiyCmhoRbNB+0SeInZkbk=",
"lastModified": 1714076141,
"narHash": "sha256-Drmja/f5MRHZCskS6mvzFqxEaZMeciScCTFxWVLqWEY=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5c24cf2f0a12ad855f444c30b2421d044120c66f",
"rev": "7bb2ccd8cdc44c91edba16c48d2c8f331fb3d856",
"type": "github"
},
"original": {
@@ -223,11 +222,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1713373173,
"narHash": "sha256-octd9BFY9G/Gbr4KfwK4itZp4Lx+qvJeRRcYnN+dEH8=",
"lastModified": 1714031783,
"narHash": "sha256-xS/niQsq1CQPOe4M4jvVPO2cnXS/EIeRG5gIopUbk+Q=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "46702ffc1a02a2ac153f1d1ce619ec917af8f3a6",
"rev": "56bee2ddafa6177b19c631eedc88d43366553223",
"type": "github"
},
"original": {

View File

@@ -525,15 +525,26 @@
};
};
devShells.default = pkgs.mkShell {
devShells.default = let
pkgs = import nixpkgs {
system = system;
overlays = [ fenix.overlays.default ];
};
in pkgs.mkShell {
buildInputs = with pkgs; [
cargo
clippy
rustc
rustfmt
rust-analyzer
(fenix.packages.${system}.complete.withComponents [
"cargo"
"clippy"
"rust-src"
"rustc"
"rustfmt"
])
cargo-deny
rust-analyzer-nightly
cargo-nextest
perl # needed to build vendored OpenSSL
git-cliff
];
};
}

View File

@@ -110,6 +110,7 @@ module.exports = {
DC_MSG_IMAGE: 20,
DC_MSG_STICKER: 23,
DC_MSG_TEXT: 10,
DC_MSG_VCARD: 90,
DC_MSG_VIDEO: 50,
DC_MSG_VIDEOCHAT_INVITATION: 70,
DC_MSG_VOICE: 41,
@@ -173,6 +174,7 @@ module.exports = {
DC_STR_CONFIGURATION_FAILED: 84,
DC_STR_CONNECTED: 107,
DC_STR_CONNTECTING: 108,
DC_STR_CONTACT: 200,
DC_STR_CONTACT_NOT_VERIFIED: 36,
DC_STR_CONTACT_SETUP_CHANGED: 37,
DC_STR_CONTACT_VERIFIED: 35,

View File

@@ -110,6 +110,7 @@ export enum C {
DC_MSG_IMAGE = 20,
DC_MSG_STICKER = 23,
DC_MSG_TEXT = 10,
DC_MSG_VCARD = 90,
DC_MSG_VIDEO = 50,
DC_MSG_VIDEOCHAT_INVITATION = 70,
DC_MSG_VOICE = 41,
@@ -173,6 +174,7 @@ export enum C {
DC_STR_CONFIGURATION_FAILED = 84,
DC_STR_CONNECTED = 107,
DC_STR_CONNTECTING = 108,
DC_STR_CONTACT = 200,
DC_STR_CONTACT_NOT_VERIFIED = 36,
DC_STR_CONTACT_SETUP_CHANGED = 37,
DC_STR_CONTACT_VERIFIED = 35,

View File

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

View File

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

View File

@@ -275,6 +275,7 @@ class ACSetup:
def __init__(self, testprocess, init_time) -> None:
self._configured_events = Queue()
self._account2state: Dict[Account, str] = {}
self._account2config: Dict[Account, Dict[str, str]] = {}
self._imap_cleaned: Set[str] = set()
self.testprocess = testprocess
self.init_time = init_time
@@ -336,6 +337,8 @@ class ACSetup:
if not success:
pytest.fail(f"configuring online account {acc} failed: {comment}")
self._account2state[acc] = self.CONFIGURED
if acc in self._account2config:
acc.update_config(self._account2config[acc])
return acc
def _onconfigure_start_io(self, acc):
@@ -523,6 +526,7 @@ class ACFactory:
configdict.setdefault("sentbox_watch", False)
configdict.setdefault("sync_msgs", False)
ac.update_config(configdict)
self._acsetup._account2config[ac] = configdict
self._preconfigure_key(ac, configdict["addr"])
return ac

View File

@@ -1 +1 @@
2024-05-13
2024-05-21

View File

@@ -485,10 +485,6 @@ impl Config {
/// Read a configuration from the given file into memory.
pub async fn from_file(file: PathBuf, writable: bool) -> Result<Self> {
let dir = file
.parent()
.context("Cannot get config file directory")?
.to_path_buf();
let mut config = Self::new_nosync(file, writable).await?;
let bytes = fs::read(&config.file)
.await
@@ -500,9 +496,13 @@ impl Config {
// Convert them to relative paths.
let mut modified = false;
for account in &mut config.inner.accounts {
if let Ok(new_dir) = account.dir.strip_prefix(&dir) {
account.dir = new_dir.to_path_buf();
modified = true;
if account.dir.is_absolute() {
if let Some(old_path_parent) = account.dir.parent() {
if let Ok(new_path) = account.dir.strip_prefix(old_path_parent) {
account.dir = new_path.to_path_buf();
modified = true;
}
}
}
}
if modified && writable {

View File

@@ -292,7 +292,7 @@ impl ChatId {
ChatIdBlocked::get_for_contact(context, contact_id, create_blocked)
.await
.map(|chat| chat.id)?;
Contact::scaleup_origin_by_id(context, contact_id, Origin::CreateChat).await?;
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat).await?;
chat_id
} else {
warn!(
@@ -489,7 +489,7 @@ impl ChatId {
// went to "contact requests" list rather than normal chatlist.
for contact_id in get_chat_contacts(context, self).await? {
if contact_id != ContactId::SELF {
Contact::scaleup_origin_by_id(context, contact_id, Origin::CreateChat)
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat)
.await?;
}
}

View File

@@ -31,10 +31,11 @@ fn rgb_to_u32((r, g, b): (f64, f64, f64)) -> u32 {
///
/// Saturation is set to maximum (100.0) to make colors distinguishable, and lightness is set to
/// half (50.0) to make colors suitable both for light and dark theme.
pub(crate) fn str_to_color(s: &str) -> u32 {
pub fn str_to_color(s: &str) -> u32 {
rgb_to_u32(hsluv_to_rgb((str_to_angle(s), 100.0, 50.0)))
}
/// Returns color as a "#RRGGBB" `String` where R, G, B are hex digits.
pub fn color_int_to_hex_string(color: u32) -> String {
format!("{color:#08x}").replace("0x", "#")
}

View File

@@ -9,7 +9,7 @@ use base64::Engine as _;
use deltachat_contact_tools::addr_cmp;
use serde::{Deserialize, Serialize};
use strum::{EnumProperty, IntoEnumIterator};
use strum_macros::{AsRefStr, Display, EnumIter, EnumProperty, EnumString};
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
use tokio::fs;
use crate::blob::BlobObject;
@@ -254,6 +254,9 @@ pub enum Config {
/// True if account is configured.
Configured,
/// True if account is a chatmail account.
IsChatmail,
/// All secondary self addresses separated by spaces
/// (`addr1@example.org addr2@example.org addr3@example.org`)
SecondaryAddrs,
@@ -359,6 +362,9 @@ pub enum Config {
/// MsgId of webxdc map integration.
WebxdcIntegration,
/// Iroh secret key.
IrohSecretKey,
}
impl Config {

View File

@@ -112,6 +112,11 @@ impl Context {
let mut param = LoginParam::load_candidate_params(self).await?;
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
// Reset our knowledge about whether the server is a chatmail server.
// We will update it when we connect to IMAP.
self.set_config_internal(Config::IsChatmail, None).await?;
let success = configure(self, &mut param).await;
self.set_config_internal(Config::NotifyAboutWrongPw, None)
.await?;
@@ -453,6 +458,14 @@ async fn configure(ctx: &Context, param: &mut LoginParam) -> Result<()> {
progress!(ctx, 900);
if imap_session.is_chatmail() {
ctx.set_config(Config::SentboxWatch, None).await?;
ctx.set_config(Config::MvboxMove, Some("0")).await?;
ctx.set_config(Config::OnlyFetchMvbox, None).await?;
ctx.set_config(Config::ShowEmails, None).await?;
ctx.set_config(Config::E2eeEnabled, Some("1")).await?;
}
let create_mvbox = ctx.should_watch_mvbox().await?;
imap.configure_folders(ctx, &mut imap_session, create_mvbox)

View File

@@ -1,6 +1,6 @@
//! Contacts module
use std::cmp::Reverse;
use std::cmp::{min, Reverse};
use std::collections::BinaryHeap;
use std::fmt;
use std::path::{Path, PathBuf};
@@ -8,10 +8,11 @@ use std::time::UNIX_EPOCH;
use anyhow::{bail, ensure, Context as _, Result};
use async_channel::{self as channel, Receiver, Sender};
use base64::Engine as _;
pub use deltachat_contact_tools::may_be_valid_addr;
use deltachat_contact_tools::{
addr_cmp, addr_normalize, normalize_name, sanitize_name_and_addr, strip_rtlo_characters,
ContactAddress,
self as contact_tools, addr_cmp, addr_normalize, sanitize_name_and_addr, strip_rtlo_characters,
ContactAddress, VcardContact,
};
use deltachat_derive::{FromSql, ToSql};
use rusqlite::OptionalExtension;
@@ -19,14 +20,15 @@ use serde::{Deserialize, Serialize};
use tokio::task;
use tokio::time::{timeout, Duration};
use crate::aheader::EncryptPreference;
use crate::aheader::{Aheader, EncryptPreference};
use crate::blob::BlobObject;
use crate::chat::{ChatId, ChatIdBlocked, ProtectionStatus};
use crate::color::str_to_color;
use crate::config::Config;
use crate::constants::{Blocked, Chattype, DC_GCL_ADD_SELF, DC_GCL_VERIFIED_ONLY};
use crate::context::Context;
use crate::events::EventType;
use crate::key::{load_self_public_key, DcKey};
use crate::key::{load_self_public_key, DcKey, SignedPublicKey};
use crate::log::LogExt;
use crate::login_param::LoginParam;
use crate::message::MessageState;
@@ -35,7 +37,9 @@ use crate::param::{Param, Params};
use crate::peerstate::Peerstate;
use crate::sql::{self, params_iter};
use crate::sync::{self, Sync::*};
use crate::tools::{duration_to_str, get_abs_path, improve_single_line_input, time, SystemTime};
use crate::tools::{
duration_to_str, get_abs_path, improve_single_line_input, smeared_time, time, SystemTime,
};
use crate::{chat, chatlist_events, stock_str};
/// Time during which a contact is considered as seen recently.
@@ -119,6 +123,29 @@ impl ContactId {
.await?;
Ok(())
}
/// Updates the origin of the contacts, but only if `origin` is higher than the current one.
pub(crate) async fn scaleup_origin(
context: &Context,
ids: &[Self],
origin: Origin,
) -> Result<()> {
context
.sql
.execute(
&format!(
"UPDATE contacts SET origin=? WHERE id IN ({}) AND origin<?",
sql::repeat_vars(ids.len())
),
rusqlite::params_from_iter(
params_iter(&[origin])
.chain(params_iter(ids))
.chain(params_iter(&[origin])),
),
)
.await?;
Ok(())
}
}
impl fmt::Display for ContactId {
@@ -159,6 +186,162 @@ impl rusqlite::types::FromSql for ContactId {
}
}
/// Returns a vCard containing contacts with the given ids.
pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result<String> {
let now = time();
let mut vcard_contacts = Vec::with_capacity(contacts.len());
for id in contacts {
let c = Contact::get_by_id(context, *id).await?;
let key = match *id {
ContactId::SELF => Some(load_self_public_key(context).await?),
_ => Peerstate::from_addr(context, &c.addr)
.await?
.and_then(|peerstate| peerstate.take_key(false)),
};
let key = key.map(|k| k.to_base64());
let profile_image = match c.get_profile_image(context).await? {
None => None,
Some(path) => tokio::fs::read(path)
.await
.log_err(context)
.ok()
.map(|data| base64::engine::general_purpose::STANDARD.encode(data)),
};
vcard_contacts.push(VcardContact {
addr: c.addr,
authname: c.authname,
key,
profile_image,
// Use the current time to not reveal our or contact's online time.
timestamp: Ok(now),
});
}
Ok(contact_tools::make_vcard(&vcard_contacts))
}
/// Imports contacts from the given vCard.
///
/// Returns the ids of successfully processed contacts in the order they appear in `vcard`,
/// regardless of whether they are just created, modified or left untouched.
pub async fn import_vcard(context: &Context, vcard: &str) -> Result<Vec<ContactId>> {
let contacts = contact_tools::parse_vcard(vcard);
let mut contact_ids = Vec::with_capacity(contacts.len());
for c in &contacts {
let Ok(id) = import_vcard_contact(context, c)
.await
.with_context(|| format!("import_vcard_contact() failed for {}", c.addr))
.log_err(context)
else {
continue;
};
contact_ids.push(id);
}
Ok(contact_ids)
}
async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Result<ContactId> {
let addr = ContactAddress::new(&contact.addr).context("Invalid address")?;
// Importing a vCard is also an explicit user action like creating a chat with the contact. We
// mustn't use `Origin::AddressBook` here because the vCard may be created not by us, also we
// want `contact.authname` to be saved as the authname and not a locally given name.
let origin = Origin::CreateChat;
let (id, modified) =
match Contact::add_or_lookup(context, &contact.authname, &addr, origin).await {
Err(e) => return Err(e).context("Contact::add_or_lookup() failed"),
Ok((ContactId::SELF, _)) => return Ok(ContactId::SELF),
Ok(val) => val,
};
if modified != Modifier::None {
context.emit_event(EventType::ContactsChanged(Some(id)));
}
let key = contact.key.as_ref().and_then(|k| {
SignedPublicKey::from_base64(k)
.with_context(|| {
format!(
"import_vcard_contact: Cannot decode key for {}",
contact.addr
)
})
.log_err(context)
.ok()
});
if let Some(public_key) = key {
let timestamp = contact
.timestamp
.as_ref()
.map_or(0, |&t| min(t, smeared_time(context)));
let aheader = Aheader {
addr: contact.addr.clone(),
public_key,
prefer_encrypt: EncryptPreference::Mutual,
};
let peerstate = match Peerstate::from_addr(context, &aheader.addr).await {
Err(e) => {
warn!(
context,
"import_vcard_contact: Cannot create peerstate from {}: {e:#}.", contact.addr
);
return Ok(id);
}
Ok(p) => p,
};
let peerstate = if let Some(mut p) = peerstate {
p.apply_gossip(&aheader, timestamp);
p
} else {
Peerstate::from_gossip(&aheader, timestamp)
};
if let Err(e) = peerstate.save_to_db(&context.sql).await {
warn!(
context,
"import_vcard_contact: Could not save peerstate for {}: {e:#}.", contact.addr
);
return Ok(id);
}
if let Err(e) = peerstate
.handle_fingerprint_change(context, timestamp)
.await
{
warn!(
context,
"import_vcard_contact: handle_fingerprint_change() failed for {}: {e:#}.",
contact.addr
);
return Ok(id);
}
}
if modified != Modifier::Created {
return Ok(id);
}
let path = match &contact.profile_image {
Some(image) => match BlobObject::store_from_base64(context, image, "avatar").await {
Err(e) => {
warn!(
context,
"import_vcard_contact: Could not decode and save avatar for {}: {e:#}.",
contact.addr
);
None
}
Ok(path) => Some(path),
},
None => None,
};
if let Some(path) = path {
// Currently this value doesn't matter as we don't import the contact of self.
let was_encrypted = false;
if let Err(e) =
set_profile_image(context, id, &AvatarAction::Change(path), was_encrypted).await
{
warn!(
context,
"import_vcard_contact: Could not set avatar for {}: {e:#}.", contact.addr
);
}
}
Ok(id)
}
/// An object representing a single contact in memory.
///
/// The contact object is not updated.
@@ -364,6 +547,10 @@ impl Contact {
{
if contact_id == ContactId::SELF {
contact.name = stock_str::self_msg(context).await;
contact.authname = context
.get_config(Config::Displayname)
.await?
.unwrap_or_default();
contact.addr = context
.get_config(Config::ConfiguredAddr)
.await?
@@ -778,7 +965,6 @@ impl Contact {
for (name, addr) in split_address_book(addr_book) {
let (name, addr) = sanitize_name_and_addr(name, addr);
let name = normalize_name(&name);
match ContactAddress::new(&addr) {
Ok(addr) => {
match Contact::add_or_lookup(context, &name, &addr, Origin::AddressBook).await {
@@ -1353,22 +1539,6 @@ impl Contact {
.await?;
Ok(exists)
}
/// Updates the origin of the contact, but only if new origin is higher than the current one.
pub async fn scaleup_origin_by_id(
context: &Context,
contact_id: ContactId,
origin: Origin,
) -> Result<()> {
context
.sql
.execute(
"UPDATE contacts SET origin=? WHERE id=? AND origin<?;",
(origin, contact_id, origin),
)
.await?;
Ok(())
}
}
pub(crate) async fn set_blocked(
@@ -1754,7 +1924,7 @@ impl RecentlySeenLoop {
#[cfg(test)]
mod tests {
use deltachat_contact_tools::may_be_valid_addr;
use deltachat_contact_tools::{may_be_valid_addr, normalize_name};
use super::*;
use crate::chat::{get_chat_contacts, send_text_msg, Chat};
@@ -2817,4 +2987,157 @@ Until the false-positive is fixed:
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_make_n_import_vcard() -> Result<()> {
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");
let avatar_base64 = base64::engine::general_purpose::STANDARD.encode(avatar_bytes);
tokio::fs::write(&avatar_path, avatar_bytes).await?;
bob.set_config(Config::Selfavatar, Some(avatar_path.to_str().unwrap()))
.await?;
let bob_addr = bob.get_config(Config::Addr).await?.unwrap();
let chat = bob.create_chat(alice).await;
let sent_msg = bob.send_text(chat.id, "moin").await;
alice.recv_msg(&sent_msg).await;
let bob_id = Contact::create(alice, "Some Bob", &bob_addr).await?;
let key_base64 = Peerstate::from_addr(alice, &bob_addr)
.await?
.unwrap()
.peek_key(false)
.unwrap()
.to_base64();
let fiona_id = Contact::create(alice, "Fiona", "fiona@example.net").await?;
assert_eq!(make_vcard(alice, &[]).await?, "".to_string());
let t0 = time();
let vcard = make_vcard(alice, &[bob_id, fiona_id]).await?;
let t1 = time();
// Just test that it's parsed as expected, `deltachat_contact_tools` crate has tests on the
// exact format.
let contacts = contact_tools::parse_vcard(&vcard);
assert_eq!(contacts.len(), 2);
assert_eq!(contacts[0].addr, bob_addr);
assert_eq!(contacts[0].authname, "Bob".to_string());
assert_eq!(*contacts[0].key.as_ref().unwrap(), key_base64);
assert_eq!(*contacts[0].profile_image.as_ref().unwrap(), avatar_base64);
let timestamp = *contacts[0].timestamp.as_ref().unwrap();
assert!(t0 <= timestamp && timestamp <= t1);
assert_eq!(contacts[1].addr, "fiona@example.net".to_string());
assert_eq!(contacts[1].authname, "".to_string());
assert_eq!(contacts[1].key, None);
assert_eq!(contacts[1].profile_image, None);
let timestamp = *contacts[1].timestamp.as_ref().unwrap();
assert!(t0 <= timestamp && timestamp <= t1);
let alice = &TestContext::new_alice().await;
alice.evtracker.clear_events();
let contact_ids = import_vcard(alice, &vcard).await?;
assert_eq!(contact_ids.len(), 2);
for _ in 0..contact_ids.len() {
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::ContactsChanged(Some(_))))
.await;
}
let vcard = make_vcard(alice, &[contact_ids[0], contact_ids[1]]).await?;
// This should be the same vCard except timestamps, check that roughly.
let contacts = contact_tools::parse_vcard(&vcard);
assert_eq!(contacts.len(), 2);
assert_eq!(contacts[0].addr, bob_addr);
assert_eq!(contacts[0].authname, "Bob".to_string());
assert_eq!(*contacts[0].key.as_ref().unwrap(), key_base64);
assert_eq!(*contacts[0].profile_image.as_ref().unwrap(), avatar_base64);
assert!(contacts[0].timestamp.is_ok());
assert_eq!(contacts[1].addr, "fiona@example.net".to_string());
let chat_id = ChatId::create_for_contact(alice, contact_ids[0]).await?;
let sent_msg = alice.send_text(chat_id, "moin").await;
let msg = bob.recv_msg(&sent_msg).await;
assert!(msg.get_showpadlock());
// Bob only actually imports Fiona, though `ContactId::SELF` is also returned.
bob.evtracker.clear_events();
let contact_ids = import_vcard(bob, &vcard).await?;
bob.emit_event(EventType::Test);
assert_eq!(contact_ids.len(), 2);
assert_eq!(contact_ids[0], ContactId::SELF);
let ev = bob
.evtracker
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. }))
.await;
assert_eq!(ev, EventType::ContactsChanged(Some(contact_ids[1])));
let ev = bob
.evtracker
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. } | EventType::Test))
.await;
assert_eq!(ev, EventType::Test);
let vcard = make_vcard(bob, &[contact_ids[1]]).await?;
let contacts = contact_tools::parse_vcard(&vcard);
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].addr, "fiona@example.net");
assert_eq!(contacts[0].authname, "".to_string());
assert_eq!(contacts[0].key, None);
assert_eq!(contacts[0].profile_image, None);
assert!(contacts[0].timestamp.is_ok());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_import_vcard_updates_only_key() -> Result<()> {
let alice = &TestContext::new_alice().await;
let bob = &TestContext::new_bob().await;
let bob_addr = &bob.get_config(Config::Addr).await?.unwrap();
bob.set_config(Config::Displayname, Some("Bob")).await?;
let vcard = make_vcard(bob, &[ContactId::SELF]).await?;
alice.evtracker.clear_events();
let alice_bob_id = import_vcard(alice, &vcard).await?[0];
let ev = alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. }))
.await;
assert_eq!(ev, EventType::ContactsChanged(Some(alice_bob_id)));
let chat_id = ChatId::create_for_contact(alice, alice_bob_id).await?;
let sent_msg = alice.send_text(chat_id, "moin").await;
let msg = bob.recv_msg(&sent_msg).await;
assert!(msg.get_showpadlock());
let bob = &TestContext::new().await;
bob.configure_addr(bob_addr).await;
bob.set_config(Config::Displayname, Some("Not Bob")).await?;
let avatar_path = bob.dir.path().join("avatar.png");
let avatar_bytes = include_bytes!("../test-data/image/avatar64x64.png");
tokio::fs::write(&avatar_path, avatar_bytes).await?;
bob.set_config(Config::Selfavatar, Some(avatar_path.to_str().unwrap()))
.await?;
SystemTime::shift(Duration::from_secs(1));
let vcard1 = make_vcard(bob, &[ContactId::SELF]).await?;
assert_eq!(import_vcard(alice, &vcard1).await?, vec![alice_bob_id]);
let alice_bob_contact = Contact::get_by_id(alice, alice_bob_id).await?;
assert_eq!(alice_bob_contact.get_authname(), "Bob");
assert_eq!(alice_bob_contact.get_profile_image(alice).await?, None);
let msg = alice.get_last_msg_in(chat_id).await;
assert!(msg.is_info());
assert_eq!(
msg.get_text(),
stock_str::contact_setup_changed(alice, bob_addr).await
);
let sent_msg = alice.send_text(chat_id, "moin").await;
let msg = bob.recv_msg(&sent_msg).await;
assert!(msg.get_showpadlock());
// The old vCard is imported, but doesn't change Bob's key for Alice.
import_vcard(alice, &vcard).await?.first().unwrap();
let sent_msg = alice.send_text(chat_id, "moin").await;
let msg = bob.recv_msg(&sent_msg).await;
assert!(msg.get_showpadlock());
Ok(())
}
}

View File

@@ -12,7 +12,7 @@ use anyhow::{bail, ensure, Context as _, Result};
use async_channel::{self as channel, Receiver, Sender};
use pgp::SignedPublicKey;
use ratelimit::Ratelimit;
use tokio::sync::{Mutex, Notify, RwLock};
use tokio::sync::{Mutex, Notify, OnceCell, RwLock};
use crate::aheader::EncryptPreference;
use crate::chat::{get_chat_cnt, ChatId, ProtectionStatus};
@@ -30,6 +30,7 @@ use crate::key::{load_self_public_key, load_self_secret_key, DcKey as _};
use crate::login_param::LoginParam;
use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::param::{Param, Params};
use crate::peer_channels::Iroh;
use crate::peerstate::Peerstate;
use crate::push::PushSubscriber;
use crate::quota::QuotaInfo;
@@ -288,6 +289,9 @@ pub struct InnerContext {
/// True if account has subscribed to push notifications via IMAP.
pub(crate) push_subscribed: AtomicBool,
/// Iroh for realtime peer channels.
pub(crate) iroh: OnceCell<Iroh>,
}
/// The state of ongoing process.
@@ -445,6 +449,7 @@ impl Context {
debug_logging: std::sync::RwLock::new(None),
push_subscriber,
push_subscribed: AtomicBool::new(false),
iroh: OnceCell::new(),
};
let ctx = Context {
@@ -461,18 +466,10 @@ impl Context {
return;
}
{
if self
.get_config(Config::ConfiguredAddr)
.await
.unwrap_or_default()
.filter(|s| s.ends_with(".testrun.org"))
.is_some()
{
let mut lock = self.ratelimit.write().await;
// Allow at least 1 message every second + a burst of 3.
*lock = Ratelimit::new(Duration::new(3, 0), 3.0);
}
if self.is_chatmail().await.unwrap_or_default() {
let mut lock = self.ratelimit.write().await;
// Allow at least 1 message every second + a burst of 3.
*lock = Ratelimit::new(Duration::new(3, 0), 3.0);
}
self.scheduler.start(self.clone()).await;
}
@@ -490,9 +487,17 @@ impl Context {
/// Indicate that the network likely has come back.
pub async fn maybe_network(&self) {
if let Some(iroh) = self.iroh.get() {
iroh.network_change().await;
}
self.scheduler.maybe_network().await;
}
/// Returns true if an account is on a chatmail server.
pub async fn is_chatmail(&self) -> Result<bool> {
self.get_config_bool(Config::IsChatmail).await
}
/// Does a background fetch
/// pauses the scheduler and does one imap fetch, then unpauses and returns
pub async fn background_fetch(&self) -> Result<()> {
@@ -799,6 +804,8 @@ impl Context {
res.insert("imap_server_id", format!("{server_id:?}"));
}
res.insert("is_chatmail", self.is_chatmail().await?.to_string());
if let Some(metadata) = &*self.metadata.read().await {
if let Some(comment) = &metadata.comment {
res.insert("imap_server_comment", format!("{comment:?}"));
@@ -1649,6 +1656,7 @@ mod tests {
"socks5_password",
"key_id",
"webxdc_integration",
"iroh_secret_key",
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();

View File

@@ -71,7 +71,17 @@ impl EventEmitter {
/// [`try_recv`]: Self::try_recv
pub async fn recv(&self) -> Option<Event> {
let mut lock = self.0.lock().await;
lock.recv().await.ok()
loop {
match lock.recv().await {
Err(async_broadcast::RecvError::Overflowed(_)) => {
// Some events have been lost,
// but the channel is not closed.
continue;
}
Err(async_broadcast::RecvError::Closed) => return None,
Ok(event) => return Some(event),
}
}
}
/// Tries to receive an event without blocking.
@@ -86,8 +96,18 @@ impl EventEmitter {
// to avoid blocking
// in case there is a concurrent call to `recv`.
let mut lock = self.0.try_lock()?;
let event = lock.try_recv()?;
Ok(event)
loop {
match lock.try_recv() {
Err(async_broadcast::TryRecvError::Overflowed(_)) => {
// Some events have been lost,
// but the channel is not closed.
continue;
}
res @ (Err(async_broadcast::TryRecvError::Empty)
| Err(async_broadcast::TryRecvError::Closed)
| Ok(_)) => return Ok(res?),
}
}
}
}

View File

@@ -279,6 +279,15 @@ pub enum EventType {
status_update_serial: StatusUpdateSerial,
},
/// Data received over an ephemeral peer channel.
WebxdcRealtimeData {
/// Message ID.
msg_id: MsgId,
/// Realtime data.
data: Vec<u8>,
},
/// Inform that a message containing a webxdc instance has been deleted.
WebxdcInstanceDeleted {
/// ID of the deleted message.
@@ -302,4 +311,8 @@ pub enum EventType {
/// ID of the changed chat
chat_id: Option<ChatId>,
},
/// Event for using in tests, e.g. as a fence between normally generated events.
#[cfg(test)]
Test,
}

View File

@@ -93,6 +93,12 @@ pub enum HeaderDef {
/// See <https://datatracker.ietf.org/doc/html/rfc8601>
AuthenticationResults,
/// Node address from iroh where direct addresses have been removed.
IrohNodeAddr,
/// Advertised gossip topic for one webxdc.
IrohGossipTopic,
#[cfg(test)]
TestHeader,
}

View File

@@ -22,6 +22,7 @@ use futures_lite::FutureExt;
use num_traits::FromPrimitive;
use rand::Rng;
use ratelimit::Ratelimit;
use url::Url;
use crate::chat::{self, ChatId, ChatIdBlocked};
use crate::chatlist_events;
@@ -111,6 +112,8 @@ pub(crate) struct ServerMetadata {
/// IMAP METADATA `/shared/admin` as defined in
/// <https://www.rfc-editor.org/rfc/rfc5464#section-6.2.2>.
pub admin: Option<String>,
pub iroh_relay: Option<Url>,
}
impl async_imap::Authenticator for OAuth2 {
@@ -1449,11 +1452,16 @@ impl Session {
let mut comment = None;
let mut admin = None;
let mut iroh_relay = None;
let mailbox = "";
let options = "";
let metadata = self
.get_metadata(mailbox, options, "(/shared/comment /shared/admin)")
.get_metadata(
mailbox,
options,
"(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay)",
)
.await?;
for m in metadata {
match m.entry.as_ref() {
@@ -1463,10 +1471,26 @@ impl Session {
"/shared/admin" => {
admin = m.value;
}
"/shared/vendor/deltachat/irohrelay" => {
if let Some(value) = m.value {
if let Ok(url) = Url::parse(&value) {
iroh_relay = Some(url);
} else {
warn!(
context,
"Got invalid URL from iroh relay metadata: {:?}.", value
);
}
}
}
_ => {}
}
}
*lock = Some(ServerMetadata { comment, admin });
*lock = Some(ServerMetadata {
comment,
admin,
iroh_relay,
});
Ok(())
}

View File

@@ -32,6 +32,14 @@ pub(crate) struct Capabilities {
/// This is supported by <https://github.com/deltachat/chatmail>
pub can_push: bool,
/// True if the server has an XCHATMAIL capability
/// indicating that it is a <https://github.com/deltachat/chatmail> server.
///
/// This can be used to hide some advanced settings in the UI
/// that are only interesting for normal email accounts,
/// e.g. the ability to move messages to Delta Chat folder.
pub is_chatmail: bool,
/// Server ID if the server supports ID capability.
pub server_id: Option<HashMap<String, String>>,
}

View File

@@ -61,6 +61,7 @@ async fn determine_capabilities(
can_condstore: caps.has_str("CONDSTORE"),
can_metadata: caps.has_str("METADATA"),
can_push: caps.has_str("XDELTAPUSH"),
is_chatmail: caps.has_str("XCHATMAIL"),
server_id,
};
Ok(capabilities)

View File

@@ -94,6 +94,11 @@ impl Session {
self.capabilities.can_push
}
// Returns true if IMAP server has `XCHATMAIL` capability.
pub fn is_chatmail(&self) -> bool {
self.capabilities.is_chatmail
}
/// Returns the names of all folders on the IMAP server.
pub async fn list_folders(&mut self) -> Result<Vec<async_imap::types::Name>> {
let list = self.list(Some(""), Some("*")).await?.try_collect().await?;

View File

@@ -38,6 +38,7 @@ use iroh::progress::ProgressEmitter;
use iroh::protocol::AuthToken;
use iroh::provider::{DataSource, Event, Provider, Ticket};
use iroh::Hash;
use iroh_old as iroh;
use tokio::fs::{self, File};
use tokio::io::{self, AsyncWriteExt, BufWriter};
use tokio::sync::broadcast::error::RecvError;

View File

@@ -94,7 +94,7 @@ pub mod webxdc;
#[macro_use]
mod dehtml;
mod authres;
mod color;
pub mod color;
pub mod html;
pub mod net;
pub mod plaintext;
@@ -106,6 +106,7 @@ pub mod receive_imf;
pub mod tools;
pub mod accounts;
pub mod peer_channels;
pub mod reaction;
/// If set IMAP/incoming and SMTP/outgoing MIME messages will be printed.

View File

@@ -885,6 +885,7 @@ mod tests {
use super::*;
use crate::config::Config;
use crate::message::MessageState;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::SystemTime;
@@ -1015,6 +1016,54 @@ Content-Disposition: attachment; filename="location.kml"
Ok(())
}
/// Tests that `location.kml` is not hidden and not seen if it contains a message.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn receive_visible_location_kml() -> Result<()> {
let alice = TestContext::new_alice().await;
receive_imf(
&alice,
br#"Subject: locations
MIME-Version: 1.0
To: <alice@example.org>
From: <bob@example.net>
Date: Tue, 21 Dec 2021 00:00:00 +0000
Chat-Version: 1.0
Message-ID: <foobar@localhost>
Content-Type: multipart/mixed; boundary="U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF"
--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Text message.
--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF
Content-Type: application/vnd.google-earth.kml+xml
Content-Disposition: attachment; filename="location.kml"
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document addr="bob@example.net">
<Placemark><Timestamp><when>2021-11-21T00:00:00Z</when></Timestamp><Point><coordinates accuracy="1.0000000000000000">10.00000000000000,20.00000000000000</coordinates></Point></Placemark>
</Document>
</kml>
--U8BOG8qNXfB0GgLiQ3PKUjlvdIuLRF--"#,
false,
)
.await?;
let received_msg = alice.get_last_msg().await;
assert_eq!(received_msg.text, "Text message.");
assert_eq!(received_msg.state, MessageState::InFresh);
let locations = get_range(&alice, None, None, 0, 0).await?;
assert_eq!(locations.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_locations_to_chat() -> Result<()> {
let alice = TestContext::new_alice().await;

View File

@@ -4,6 +4,7 @@ use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use anyhow::{ensure, format_err, Context as _, Result};
use deltachat_contact_tools::{parse_vcard, VcardContact};
use deltachat_derive::{FromSql, ToSql};
use serde::{Deserialize, Serialize};
use tokio::{fs, io};
@@ -607,6 +608,20 @@ impl Message {
self.param.get_path(Param::File, context).unwrap_or(None)
}
/// Returns vector of vcards if the file has a vCard attachment.
pub async fn vcard_contacts(&self, context: &Context) -> Result<Vec<VcardContact>> {
if self.viewtype != Viewtype::Vcard {
return Ok(Vec::new());
}
let path = self
.get_file(context)
.context("vCard message does not have an attachment")?;
let bytes = tokio::fs::read(path).await?;
let vcard_contents = std::str::from_utf8(&bytes).context("vCard is not a valid UTF-8")?;
Ok(parse_vcard(vcard_contents))
}
/// Save file copy at the user-provided path.
pub async fn save_file(&self, context: &Context, path: &Path) -> Result<()> {
let path_src = self.get_file(context).context("No file")?;
@@ -1416,8 +1431,8 @@ pub(crate) fn guess_msgtype_from_suffix(path: &Path) -> Option<(Viewtype, &str)>
"tif" => (Viewtype::File, "image/tiff"),
"ttf" => (Viewtype::File, "font/ttf"),
"txt" => (Viewtype::File, "text/plain"),
"vcard" => (Viewtype::File, "text/vcard"),
"vcf" => (Viewtype::File, "text/vcard"),
"vcard" => (Viewtype::Vcard, "text/vcard"),
"vcf" => (Viewtype::Vcard, "text/vcard"),
"wav" => (Viewtype::File, "audio/wav"),
"weba" => (Viewtype::File, "audio/webm"),
"webm" => (Viewtype::Video, "video/webm"),
@@ -1938,7 +1953,8 @@ pub enum Viewtype {
Text = 10,
/// Image message.
/// If the image is an animated GIF, the type DC_MSG_GIF should be used.
/// If the image is a GIF and has the appropriate extension, the viewtype is auto-changed to
/// `Gif` when sending the message.
/// File, width and height are set via dc_msg_set_file(), dc_msg_set_dimension
/// and retrieved via dc_msg_set_file(), dc_msg_set_dimension().
Image = 20,
@@ -1982,6 +1998,11 @@ pub enum Viewtype {
/// Message is an webxdc instance.
Webxdc = 80,
/// Message containing shared contacts represented as a vCard (virtual contact file)
/// with email addresses and possibly other fields.
/// Use `parse_vcard()` to retrieve them.
Vcard = 90,
}
impl Viewtype {
@@ -1999,6 +2020,7 @@ impl Viewtype {
Viewtype::File => true,
Viewtype::VideochatInvitation => false,
Viewtype::Webxdc => true,
Viewtype::Vcard => true,
}
}
}
@@ -2270,6 +2292,7 @@ mod tests {
async fn test_set_override_sender_name() {
// send message with overridden sender name
let alice = TestContext::new_alice().await;
let alice2 = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let contact_id = *chat::get_chat_contacts(&alice, chat.id)
@@ -2289,6 +2312,7 @@ mod tests {
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
chat::send_msg(&alice, chat.id, &mut msg).await.unwrap();
let sent_msg = alice.pop_sent_msg().await;
// bob receives that message
let chat = bob.create_chat(&alice).await;
@@ -2298,7 +2322,7 @@ mod tests {
.first()
.unwrap();
let contact = Contact::get_by_id(&bob, contact_id).await.unwrap();
let msg = bob.recv_msg(&alice.pop_sent_msg().await).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, chat.id);
assert_eq!(msg.text, "bla blubb");
assert_eq!(
@@ -2312,6 +2336,13 @@ mod tests {
// (mailing lists may also use `Sender:`-header)
let chat = Chat::load_from_db(&bob, msg.chat_id).await.unwrap();
assert_ne!(chat.typ, Chattype::Mailinglist);
// Alice receives message on another device.
let msg = alice2.recv_msg(&sent_msg).await;
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -2512,6 +2543,7 @@ mod tests {
Viewtype::from_i32(70).unwrap()
);
assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap());
assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -13,15 +13,16 @@ use crate::blob::BlobObject;
use crate::chat::{self, Chat};
use crate::config::Config;
use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
use crate::contact::Contact;
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::e2ee::EncryptHelper;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::headerdef::HeaderDef;
use crate::html::new_html_mimepart;
use crate::location;
use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::param::Param;
use crate::peer_channels::create_iroh_header;
use crate::peerstate::Peerstate;
use crate::simplify::escape_message_footer_marks;
use crate::stock_str;
@@ -29,6 +30,7 @@ use crate::tools::IsNoneOrEmpty;
use crate::tools::{
create_outgoing_rfc724_mid, create_smeared_timestamp, remove_subject_prefix, time,
};
use crate::{location, peer_channels};
// attachments of 25 mb brutto should work on the majority of providers
// (brutto examples: web.de=50, 1&1=40, t-online.de=32, gmail=25, posteo=50, yahoo=25, all-inkl=100).
@@ -153,6 +155,7 @@ impl<'a> MimeFactory<'a> {
};
let mut recipients = Vec::with_capacity(5);
let mut recipient_ids = HashSet::new();
let mut req_mdn = false;
if chat.is_self_talk() {
@@ -167,7 +170,7 @@ impl<'a> MimeFactory<'a> {
context
.sql
.query_map(
"SELECT c.authname, c.addr \
"SELECT c.authname, c.addr, c.id \
FROM chats_contacts cc \
LEFT JOIN contacts c ON cc.contact_id=c.id \
WHERE cc.chat_id=? AND cc.contact_id>9;",
@@ -175,19 +178,23 @@ impl<'a> MimeFactory<'a> {
|row| {
let authname: String = row.get(0)?;
let addr: String = row.get(1)?;
Ok((authname, addr))
let id: ContactId = row.get(2)?;
Ok((authname, addr, id))
},
|rows| {
for row in rows {
let (authname, addr) = row?;
let (authname, addr, id) = row?;
if !recipients_contain_addr(&recipients, &addr) {
recipients.push((authname, addr));
}
recipient_ids.insert(id);
}
Ok(())
},
)
.await?;
let recipient_ids: Vec<_> = recipient_ids.into_iter().collect();
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
if !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
@@ -1148,6 +1155,18 @@ impl<'a> MimeFactory<'a> {
"protection-disabled".to_string(),
));
}
SystemMessage::IrohNodeAddr => {
headers.protected.push(Header::new(
HeaderDef::IrohNodeAddr.get_headername().to_string(),
serde_json::to_string(
&context
.get_or_try_init_peer_channel()
.await?
.get_node_addr()
.await?,
)?,
));
}
_ => {}
}
@@ -1314,6 +1333,10 @@ impl<'a> MimeFactory<'a> {
let json = self.msg.param.get(Param::Arg).unwrap_or_default();
parts.push(context.build_status_update_part(json));
} else if self.msg.viewtype == Viewtype::Webxdc {
let topic = peer_channels::create_random_topic();
headers
.protected
.push(create_iroh_header(context, topic, self.msg.id).await?);
if let Some(json) = context
.render_webxdc_status_update_object(self.msg.id, None)
.await?

View File

@@ -199,6 +199,9 @@ pub enum SystemMessage {
/// Webxdc info added with `info` set in `send_webxdc_status_update()`.
WebxdcInfoMessage = 32,
/// This message contains a users iroh node address.
IrohNodeAddr = 40,
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
@@ -556,19 +559,25 @@ impl MimeMessage {
/// Parses avatar action headers.
async fn parse_avatar_headers(&mut self, context: &Context) {
if let Some(header_value) = self.get_header(HeaderDef::ChatGroupAvatar).cloned() {
self.group_avatar = self.avatar_action_from_header(context, header_value).await;
if let Some(header_value) = self.get_header(HeaderDef::ChatGroupAvatar) {
self.group_avatar = self
.avatar_action_from_header(context, header_value.to_string())
.await;
}
if let Some(header_value) = self.get_header(HeaderDef::ChatUserAvatar).cloned() {
self.user_avatar = self.avatar_action_from_header(context, header_value).await;
if let Some(header_value) = self.get_header(HeaderDef::ChatUserAvatar) {
self.user_avatar = self
.avatar_action_from_header(context, header_value.to_string())
.await;
}
}
fn parse_videochat_headers(&mut self) {
if let Some(value) = self.get_header(HeaderDef::ChatContent).cloned() {
if let Some(value) = self.get_header(HeaderDef::ChatContent) {
if value == "videochat-invitation" {
let instance = self.get_header(HeaderDef::ChatWebrtcRoom).cloned();
let instance = self
.get_header(HeaderDef::ChatWebrtcRoom)
.map(|s| s.to_string());
if let Some(part) = self.parts.first_mut() {
part.typ = Viewtype::VideochatInvitation;
part.param
@@ -594,6 +603,7 @@ impl MimeMessage {
| Viewtype::Audio
| Viewtype::Voice
| Viewtype::Video
| Viewtype::Vcard
| Viewtype::File
| Viewtype::Webxdc => true,
Viewtype::Unknown | Viewtype::Text | Viewtype::VideochatInvitation => false,
@@ -815,12 +825,14 @@ impl MimeMessage {
.map(|s| s.to_string())
}
pub fn get_header(&self, headerdef: HeaderDef) -> Option<&String> {
self.headers.get(headerdef.get_headername())
pub fn get_header(&self, headerdef: HeaderDef) -> Option<&str> {
self.headers
.get(headerdef.get_headername())
.map(|s| s.as_str())
}
/// Returns `Chat-Group-ID` header value if it is a valid group ID.
pub fn get_chat_group_id(&self) -> Option<&String> {
pub fn get_chat_group_id(&self) -> Option<&str> {
self.get_header(HeaderDef::ChatGroupId)
.filter(|s| validate_id(s))
}
@@ -1575,9 +1587,8 @@ impl MimeMessage {
.get_header_value(HeaderDef::MessageId)
.and_then(|v| parse_message_id(&v).ok())
{
let mut to_list = get_all_addresses_from_header(&report.headers, |header_key| {
header_key == "x-failed-recipients"
});
let mut to_list =
get_all_addresses_from_header(&report.headers, "x-failed-recipients");
let to = if to_list.len() == 1 {
Some(to_list.pop().unwrap())
} else {
@@ -1925,16 +1936,11 @@ fn get_mime_type(
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
let viewtype = match mimetype.type_() {
mime::TEXT => {
if !is_attachment_disposition(mail) {
match mimetype.subtype() {
mime::PLAIN | mime::HTML => Viewtype::Text,
_ => Viewtype::File,
}
} else {
Viewtype::File
}
}
mime::TEXT => match mimetype.subtype() {
mime::VCARD if is_valid_deltachat_vcard(mail) => Viewtype::Vcard,
mime::PLAIN | mime::HTML if !is_attachment_disposition(mail) => Viewtype::Text,
_ => Viewtype::File,
},
mime::IMAGE => match mimetype.subtype() {
mime::GIF => Viewtype::Gif,
mime::SVG => Viewtype::File,
@@ -1982,6 +1988,17 @@ fn is_attachment_disposition(mail: &mailparse::ParsedMail<'_>) -> bool {
.any(|(key, _value)| key.starts_with("filename"))
}
fn is_valid_deltachat_vcard(mail: &mailparse::ParsedMail) -> bool {
let Ok(body) = &mail.get_body() else {
return false;
};
let contacts = deltachat_contact_tools::parse_vcard(body);
if let [c] = &contacts[..] {
return deltachat_contact_tools::may_be_valid_addr(&c.addr);
}
false
}
/// Tries to get attachment filename.
///
/// If filename is explicitly specified in Content-Disposition, it is
@@ -2038,36 +2055,48 @@ fn get_attachment_filename(
/// Returned addresses are normalized and lowercased.
pub(crate) fn get_recipients(headers: &[MailHeader]) -> Vec<SingleInfo> {
get_all_addresses_from_header(headers, |header_key| {
header_key == "to" || header_key == "cc"
})
let to_addresses = get_all_addresses_from_header(headers, "to");
let cc_addresses = get_all_addresses_from_header(headers, "cc");
let mut res = to_addresses;
res.extend(cc_addresses);
res
}
/// Returned addresses are normalized and lowercased.
pub(crate) fn get_from(headers: &[MailHeader]) -> Option<SingleInfo> {
let all = get_all_addresses_from_header(headers, |header_key| header_key == "from");
let all = get_all_addresses_from_header(headers, "from");
tools::single_value(all)
}
/// Returned addresses are normalized and lowercased.
pub(crate) fn get_list_post(headers: &[MailHeader]) -> Option<String> {
get_all_addresses_from_header(headers, |header_key| header_key == "list-post")
get_all_addresses_from_header(headers, "list-post")
.into_iter()
.next()
.map(|s| s.addr)
}
fn get_all_addresses_from_header(
headers: &[MailHeader],
pred: fn(String) -> bool,
) -> Vec<SingleInfo> {
/// Extracts all addresses from the header named `header`.
///
/// If multiple headers with the same name are present,
/// the last one is taken.
/// This is because DKIM-Signatures apply to the last
/// headers, and more headers may be added
/// to the beginning of the messages
/// without invalidating the signature
/// unless the header is "oversigned",
/// i.e. included in the signature more times
/// than it appears in the mail.
fn get_all_addresses_from_header(headers: &[MailHeader], header: &str) -> Vec<SingleInfo> {
let mut result: Vec<SingleInfo> = Default::default();
headers
if let Some(header) = headers
.iter()
.filter(|header| pred(header.get_key().to_lowercase()))
.filter_map(|header| mailparse::addrparse_header(header).ok())
.for_each(|addrs| {
.rev()
.find(|h| h.get_key().to_lowercase() == header)
{
if let Ok(addrs) = mailparse::addrparse_header(header) {
for addr in addrs.iter() {
match addr {
mailparse::MailAddr::Single(ref info) => {
@@ -2086,7 +2115,8 @@ fn get_all_addresses_from_header(
}
}
}
});
}
}
result
}
@@ -2386,6 +2416,22 @@ mod tests {
assert!(recipients.iter().any(|info| info.addr == "abc@bcd.com"));
assert!(recipients.iter().any(|info| info.addr == "def@def.de"));
assert_eq!(recipients.len(), 2);
// If some header is present multiple times,
// only the last one must be used.
let raw = b"From: alice@example.org\n\
TO: mallory@example.com\n\
To: mallory@example.net\n\
To: bob@example.net\n\
Content-Type: text/plain\n\
Chat-Version: 1.0\n\
\n\
Hello\n\
";
let mail = mailparse::parse_mail(&raw[..]).unwrap();
let recipients = get_recipients(&mail.headers);
assert!(recipients.iter().any(|info| info.addr == "bob@example.net"));
assert_eq!(recipients.len(), 1);
}
#[test]
@@ -3968,4 +4014,43 @@ Content-Type: text/plain; charset=utf-8
// Not "Some subject /help"
assert_eq!(message.parts[0].msg, "/help");
}
/// Tests that Delta Chat takes the last header value
/// rather than the first one if multiple headers
/// are present.
///
/// DKIM signature applies to the last N headers
/// if header name is included N times in
/// DKIM-Signature.
///
/// If the client takes the first header
/// rather than the last, it can be fooled
/// into using unsigned header
/// when signed one is present
/// but not protected by oversigning.
///
/// See
/// <https://www.zone.eu/blog/2024/05/17/bimi-and-dmarc-cant-save-you/>
/// for reference.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_take_last_header() {
let context = TestContext::new().await;
// Mallory added second From: header.
let raw = b"From: mallory@example.org\n\
From: alice@example.org\n\
Content-Type: text/plain\n\
Chat-Version: 1.0\n\
\n\
Hello\n\
";
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
.await
.unwrap();
assert_eq!(
mimeparser.get_header(HeaderDef::From_).unwrap(),
"alice@example.org"
);
}
}

711
src/peer_channels.rs Normal file
View File

@@ -0,0 +1,711 @@
//! Peer channels for realtime communication in webxdcs.
//!
//! We use Iroh as an ephemeral peer channels provider to create direct communication
//! channels between webxdcs. See [here](https://webxdc.org/docs/spec/joinRealtimeChannel.html) for the webxdc specs.
//!
//! Ephemeral channels should be established lazily, to avoid bootstrapping p2p connectivity
//! when it's not required. Only when a webxdc subscribes to realtime data or when a reatlime message is sent,
//! the p2p machinery should be started.
//!
//! Adding peer channels to webxdc needs upfront negotation of a topic and sharing of public keys so that
//! nodes can connect to each other. The explicit approach is as follows:
//!
//! 1. We introduce a new [GossipTopic](crate::headerdef::HeaderDef::IrohGossipTopic) message header with a random 32-byte TopicId,
//! securely generated on the initial webxdc sender's device. This message header is encrypted
//! and sent in the same message as the webxdc application.
//! 2. Whenever `joinRealtimeChannel().setListener()` or `joinRealtimeChannel().send()` is called by the webxdc application,
//! we start a routine to establish p2p connectivity and join the gossip swarm with Iroh.
//! 3. The first step of this routine is to introduce yourself with a regular message containing the `IrohPublicKey`.
//! This message contains the users relay-server and public key.
//! Direct IP address is not included as this information can be persisted by email providers.
//! 4. After the announcement, the sending peer joins the gossip swarm with an empty list of peer IDs (as they don't know anyone yet).
//! 5. Upon receiving an announcement message, other peers store the sender's [NodeAddr] in the database
//! (scoped per WebXDC app instance/message-id). The other peers can then join the gossip with `joinRealtimeChannel().setListener()`
//! and `joinRealtimeChannel().send()` just like the other peers.
use anyhow::{anyhow, Context as _, Result};
use email::Header;
use iroh_gossip::net::{Gossip, JoinTopicFut, GOSSIP_ALPN};
use iroh_gossip::proto::{Event as IrohEvent, TopicId};
use iroh_net::relay::{RelayMap, RelayUrl};
use iroh_net::{key::SecretKey, relay::RelayMode, MagicEndpoint};
use iroh_net::{NodeAddr, NodeId};
use std::collections::{BTreeSet, HashMap};
use std::env;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use url::Url;
use crate::chat::send_msg;
use crate::context::Context;
use crate::headerdef::HeaderDef;
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::EventType;
/// The length of an ed25519 `PublicKey`, in bytes.
const PUBLIC_KEY_LENGTH: usize = 32;
const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
/// Store iroh peer channels for the context.
#[derive(Debug)]
pub struct Iroh {
/// [MagicEndpoint] needed for iroh peer channels.
pub(crate) endpoint: MagicEndpoint,
/// [Gossip] needed for iroh peer channels.
pub(crate) gossip: Gossip,
/// Topics for which an advertisement has already been sent.
pub(crate) iroh_channels: RwLock<HashMap<TopicId, ChannelState>>,
/// Currently used Iroh secret key
pub(crate) secret_key: SecretKey,
}
impl Iroh {
/// Notify the endpoint that the network has changed.
pub(crate) async fn network_change(&self) {
self.endpoint.network_change().await
}
/// Join a topic and create the subscriber loop for it.
///
/// If there is no gossip, create it.
///
/// The returned future resolves when the swarm becomes operational.
async fn join_and_subscribe_gossip(
&self,
ctx: &Context,
msg_id: MsgId,
) -> Result<Option<JoinTopicFut>> {
let topic = get_iroh_topic_for_msg(ctx, msg_id).await?;
let seq = if let Some(channel_state) = self.iroh_channels.read().await.get(&topic) {
if channel_state.subscribe_loop.is_some() {
return Ok(None);
}
channel_state.seq_number
} else {
0
};
let peers = get_iroh_gossip_peers(ctx, msg_id).await?;
info!(
ctx,
"IROH_REALTIME: Joining gossip with peers: {:?}",
peers.iter().map(|p| p.node_id).collect::<Vec<_>>()
);
// Connect to all peers
for peer in &peers {
self.endpoint.add_node_addr(peer.clone())?;
}
let connect_future = self
.gossip
.join(topic, peers.into_iter().map(|addr| addr.node_id).collect())
.await?;
let ctx = ctx.clone();
let gossip = self.gossip.clone();
let subscribe_loop = tokio::spawn(async move {
if let Err(e) = subscribe_loop(&ctx, gossip, topic, msg_id).await {
warn!(ctx, "subscribe_loop failed: {e}")
}
});
self.iroh_channels
.write()
.await
.insert(topic, ChannelState::new(seq, subscribe_loop));
Ok(Some(connect_future))
}
/// Send realtime data to the gossip swarm.
pub async fn send_webxdc_realtime_data(
&self,
ctx: &Context,
msg_id: MsgId,
mut data: Vec<u8>,
) -> Result<()> {
let topic = get_iroh_topic_for_msg(ctx, msg_id).await?;
self.join_and_subscribe_gossip(ctx, msg_id).await?;
let seq_num = self.get_and_incr(&topic).await;
data.extend(seq_num.to_le_bytes());
data.extend(self.secret_key.public().as_bytes());
self.gossip.broadcast(topic, data.into()).await?;
if env::var("REALTIME_DEBUG").is_ok() {
info!(ctx, "Sent realtime data");
}
Ok(())
}
async fn get_and_incr(&self, topic: &TopicId) -> i32 {
let mut seq = 0;
if let Some(state) = self.iroh_channels.write().await.get_mut(topic) {
seq = state.seq_number;
state.seq_number = state.seq_number.wrapping_add(1)
}
seq
}
/// Get the iroh [NodeAddr] without direct IP addresses.
pub(crate) async fn get_node_addr(&self) -> Result<NodeAddr> {
let mut addr = self.endpoint.my_addr().await?;
addr.info.direct_addresses = BTreeSet::new();
Ok(addr)
}
/// Leave the realtime channel for a given topic.
pub(crate) async fn leave_realtime(&self, topic: TopicId) -> Result<()> {
if let Some(channel) = &mut self.iroh_channels.write().await.get_mut(&topic) {
if let Some(subscribe_loop) = channel.subscribe_loop.take() {
subscribe_loop.abort();
}
}
self.gossip.quit(topic).await?;
Ok(())
}
}
/// Single gossip channel state.
#[derive(Debug)]
pub(crate) struct ChannelState {
/// Sequence number for the gossip channel.
seq_number: i32,
/// The subscribe loop handle.
subscribe_loop: Option<JoinHandle<()>>,
}
impl ChannelState {
fn new(seq_number: i32, subscribe_loop: JoinHandle<()>) -> Self {
Self {
seq_number,
subscribe_loop: Some(subscribe_loop),
}
}
}
impl Context {
/// Create magic endpoint and gossip.
async fn init_peer_channels(&self) -> Result<Iroh> {
let secret_key: SecretKey = SecretKey::generate();
let relay_mode = if let Some(relay_url) = self
.metadata
.read()
.await
.as_ref()
.and_then(|conf| conf.iroh_relay.clone())
{
RelayMode::Custom(RelayMap::from_url(RelayUrl::from(relay_url)))
} else {
// FIXME: this should be RelayMode::Disabled instead.
// Currently using default relays because otherwise Rust tests fail.
RelayMode::Default
};
let endpoint = MagicEndpoint::builder()
.secret_key(secret_key.clone())
.alpns(vec![GOSSIP_ALPN.to_vec()])
.relay_mode(relay_mode)
.bind(0)
.await?;
// create gossip
let my_addr = endpoint.my_addr().await?;
let gossip = Gossip::from_endpoint(endpoint.clone(), Default::default(), &my_addr.info);
// spawn endpoint loop that forwards incoming connections to the gossiper
let context = self.clone();
// Shuts down on deltachat shutdown
tokio::spawn(endpoint_loop(context, endpoint.clone(), gossip.clone()));
Ok(Iroh {
endpoint,
gossip,
iroh_channels: RwLock::new(HashMap::new()),
secret_key,
})
}
/// Get or initialize the iroh peer channel.
pub async fn get_or_try_init_peer_channel(&self) -> Result<&Iroh> {
let ctx = self.clone();
self.iroh
.get_or_try_init(|| async { ctx.init_peer_channels().await })
.await
}
}
/// Cache a peers [NodeId] for one topic.
pub(crate) async fn iroh_add_peer_for_topic(
ctx: &Context,
msg_id: MsgId,
topic: TopicId,
peer: NodeId,
relay_server: Option<&str>,
) -> Result<()> {
ctx.sql
.execute(
"INSERT OR REPLACE INTO iroh_gossip_peers (msg_id, public_key, topic, relay_server) VALUES (?, ?, ?, ?)",
(msg_id, peer.as_bytes(), topic.as_bytes(), relay_server),
)
.await?;
Ok(())
}
/// Insert topicId into the database so that we can use it to retrieve the topic.
pub(crate) async fn insert_topic_stub(ctx: &Context, msg_id: MsgId, topic: TopicId) -> Result<()> {
ctx.sql
.execute(
"INSERT OR REPLACE INTO iroh_gossip_peers (msg_id, public_key, topic, relay_server) VALUES (?, ?, ?, ?)",
(msg_id, PUBLIC_KEY_STUB, topic.as_bytes(), Option::<&str>::None),
)
.await?;
Ok(())
}
/// Get a list of [NodeAddr]s for one webxdc.
async fn get_iroh_gossip_peers(ctx: &Context, msg_id: MsgId) -> Result<Vec<NodeAddr>> {
ctx.sql
.query_map(
"SELECT public_key, relay_server FROM iroh_gossip_peers WHERE msg_id = ? AND public_key != ?",
(msg_id, PUBLIC_KEY_STUB),
|row| {
let key: Vec<u8> = row.get(0)?;
let server: Option<String> = row.get(1)?;
Ok((key, server))
},
|g| {
g.map(|data| {
let (key, server) = data?;
let server = server.map(|data| Ok::<_, url::ParseError>(RelayUrl::from(Url::parse(&data)?))).transpose()?;
let id = NodeId::from_bytes(&key.try_into()
.map_err(|_| anyhow!("Can't convert sql data to [u8; 32]"))?)?;
Ok::<_, anyhow::Error>(NodeAddr::from_parts(
id, server, vec![]
))
})
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await
}
/// Get the topic for a given [MsgId].
pub(crate) async fn get_iroh_topic_for_msg(ctx: &Context, msg_id: MsgId) -> Result<TopicId> {
let bytes: Vec<u8> = ctx
.sql
.query_get_value(
"SELECT topic FROM iroh_gossip_peers WHERE msg_id = ? LIMIT 1",
(msg_id,),
)
.await?
.context("couldn't restore topic from db")?;
Ok(TopicId::from_bytes(bytes.try_into().unwrap()))
}
/// Send a gossip advertisement to the chat that [MsgId] belongs to.
/// This method should be called from the frontend when `joinRealtimeChannel` is called.
pub async fn send_webxdc_realtime_advertisement(
ctx: &Context,
msg_id: MsgId,
) -> Result<Option<JoinTopicFut>> {
let iroh = ctx.get_or_try_init_peer_channel().await?;
let conn = iroh.join_and_subscribe_gossip(ctx, msg_id).await?;
let webxdc = Message::load_from_db(ctx, msg_id).await?;
let mut msg = Message::new(Viewtype::Text);
msg.hidden = true;
msg.param.set_cmd(SystemMessage::IrohNodeAddr);
msg.in_reply_to = Some(webxdc.rfc724_mid.clone());
send_msg(ctx, webxdc.chat_id, &mut msg).await?;
info!(ctx, "IROH_REALTIME: Sent realtime advertisement");
Ok(conn)
}
/// Send realtime data to the gossip swarm.
pub async fn send_webxdc_realtime_data(ctx: &Context, msg_id: MsgId, data: Vec<u8>) -> Result<()> {
let iroh = ctx.get_or_try_init_peer_channel().await?;
iroh.send_webxdc_realtime_data(ctx, msg_id, data).await?;
Ok(())
}
/// Leave the gossip of the webxdc with given [MsgId].
pub async fn leave_webxdc_realtime(ctx: &Context, msg_id: MsgId) -> Result<()> {
let iroh = ctx.get_or_try_init_peer_channel().await?;
iroh.leave_realtime(get_iroh_topic_for_msg(ctx, msg_id).await?)
.await?;
info!(ctx, "IROH_REALTIME: Left gossip for message {msg_id}");
Ok(())
}
pub(crate) fn create_random_topic() -> TopicId {
TopicId::from_bytes(rand::random())
}
pub(crate) async fn create_iroh_header(
ctx: &Context,
topic: TopicId,
msg_id: MsgId,
) -> Result<Header> {
insert_topic_stub(ctx, msg_id, topic).await?;
Ok(Header::new(
HeaderDef::IrohGossipTopic.get_headername().to_string(),
topic.to_string(),
))
}
async fn endpoint_loop(context: Context, endpoint: MagicEndpoint, gossip: Gossip) {
while let Some(conn) = endpoint.accept().await {
info!(context, "IROH_REALTIME: accepting iroh connection");
let gossip = gossip.clone();
let context = context.clone();
tokio::spawn(async move {
if let Err(err) = handle_connection(&context, conn, gossip).await {
warn!(context, "IROH_REALTIME: iroh connection error: {err}");
}
});
}
}
async fn handle_connection(
context: &Context,
mut conn: iroh_net::magic_endpoint::Connecting,
gossip: Gossip,
) -> anyhow::Result<()> {
let alpn = conn.alpn().await?;
let conn = conn.await?;
let peer_id = iroh_net::magic_endpoint::get_remote_node_id(&conn)?;
match alpn.as_bytes() {
GOSSIP_ALPN => gossip
.handle_connection(conn)
.await
.context(format!("Connection to {peer_id} with ALPN {alpn} failed"))?,
_ => warn!(
context,
"Ignoring connection from {peer_id}: unsupported ALPN protocol"
),
}
Ok(())
}
async fn subscribe_loop(
context: &Context,
gossip: Gossip,
topic: TopicId,
msg_id: MsgId,
) -> Result<()> {
let mut stream = gossip.subscribe(topic).await?;
loop {
let event = stream.recv().await?;
match event {
IrohEvent::NeighborUp(node) => {
info!(context, "IROH_REALTIME: NeighborUp: {}", node.to_string());
iroh_add_peer_for_topic(context, msg_id, topic, node, None).await?;
}
IrohEvent::Received(event) => {
info!(context, "IROH_REALTIME: Received realtime data");
context.emit_event(EventType::WebxdcRealtimeData {
msg_id,
data: event
.content
.get(0..event.content.len() - 4 - PUBLIC_KEY_LENGTH)
.context("too few bytes in iroh message")?
.into(),
});
}
_ => (),
};
}
}
#[cfg(test)]
mod tests {
use crate::{
chat::send_msg,
message::{Message, Viewtype},
peer_channels::{
get_iroh_gossip_peers, get_iroh_topic_for_msg, leave_webxdc_realtime,
send_webxdc_realtime_advertisement,
},
test_utils::TestContextManager,
EventType,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_can_communicate() {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
// Alice sends webxdc to bob
let alice_chat = alice.create_chat(bob).await;
let mut instance = Message::new(Viewtype::File);
instance
.set_file_from_bytes(
alice,
"minimal.xdc",
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.await
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
let alice_webxdc = alice.get_last_msg().await;
assert_eq!(alice_webxdc.get_viewtype(), Viewtype::Webxdc);
let webxdc = alice.pop_sent_msg().await;
let bob_webdxc = bob.recv_msg(&webxdc).await;
assert_eq!(bob_webdxc.get_viewtype(), Viewtype::Webxdc);
bob_webdxc.chat_id.accept(bob).await.unwrap();
// Alice advertises herself.
send_webxdc_realtime_advertisement(alice, alice_webxdc.id)
.await
.unwrap();
bob.recv_msg_trash(&alice.pop_sent_msg().await).await;
let bob_iroh = bob.get_or_try_init_peer_channel().await.unwrap();
// Bob adds alice to gossip peers.
let members = get_iroh_gossip_peers(bob, bob_webdxc.id)
.await
.unwrap()
.into_iter()
.map(|addr| addr.node_id)
.collect::<Vec<_>>();
let alice_iroh = alice.get_or_try_init_peer_channel().await.unwrap();
assert_eq!(
members,
vec![alice_iroh.get_node_addr().await.unwrap().node_id]
);
bob_iroh
.join_and_subscribe_gossip(bob, bob_webdxc.id)
.await
.unwrap()
.unwrap()
.await
.unwrap();
// Alice sends ephemeral message
alice_iroh
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "alice -> bob".as_bytes() {
break;
} else {
panic!(
"Unexpected status update: {}",
String::from_utf8_lossy(&data)
);
}
}
}
// Bob sends ephemeral message
bob_iroh
.send_webxdc_realtime_data(bob, bob_webdxc.id, "bob -> alice".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice".as_bytes() {
break;
} else {
panic!(
"Unexpected status update: {}",
String::from_utf8_lossy(&data)
);
}
}
}
// Alice adds bob to gossip peers.
let members = get_iroh_gossip_peers(alice, alice_webxdc.id)
.await
.unwrap()
.into_iter()
.map(|addr| addr.node_id)
.collect::<Vec<_>>();
assert_eq!(
members,
vec![bob_iroh.get_node_addr().await.unwrap().node_id]
);
bob_iroh
.send_webxdc_realtime_data(bob, bob_webdxc.id, "bob -> alice 2".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice 2".as_bytes() {
break;
} else {
panic!(
"Unexpected status update: {}",
String::from_utf8_lossy(&data)
);
}
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_can_reconnect() {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
// Alice sends webxdc to bob
let alice_chat = alice.create_chat(bob).await;
let mut instance = Message::new(Viewtype::File);
instance
.set_file_from_bytes(
alice,
"minimal.xdc",
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.await
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
let alice_webxdc = alice.get_last_msg().await;
assert_eq!(alice_webxdc.get_viewtype(), Viewtype::Webxdc);
let webxdc = alice.pop_sent_msg().await;
let bob_webdxc = bob.recv_msg(&webxdc).await;
assert_eq!(bob_webdxc.get_viewtype(), Viewtype::Webxdc);
bob_webdxc.chat_id.accept(bob).await.unwrap();
// Alice advertises herself.
send_webxdc_realtime_advertisement(alice, alice_webxdc.id)
.await
.unwrap();
bob.recv_msg_trash(&alice.pop_sent_msg().await).await;
let bob_iroh = bob.get_or_try_init_peer_channel().await.unwrap();
// Bob adds alice to gossip peers.
let members = get_iroh_gossip_peers(bob, bob_webdxc.id)
.await
.unwrap()
.into_iter()
.map(|addr| addr.node_id)
.collect::<Vec<_>>();
let alice_iroh = alice.get_or_try_init_peer_channel().await.unwrap();
assert_eq!(
members,
vec![alice_iroh.get_node_addr().await.unwrap().node_id]
);
bob_iroh
.join_and_subscribe_gossip(bob, bob_webdxc.id)
.await
.unwrap()
.unwrap()
.await
.unwrap();
// Alice sends ephemeral message
alice_iroh
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "alice -> bob".as_bytes() {
break;
} else {
panic!(
"Unexpected status update: {}",
String::from_utf8_lossy(&data)
);
}
}
}
// TODO: check that seq number is persisted
leave_webxdc_realtime(bob, bob_webdxc.id).await.unwrap();
bob_iroh
.join_and_subscribe_gossip(bob, bob_webdxc.id)
.await
.unwrap()
.unwrap()
.await
.unwrap();
bob_iroh
.send_webxdc_realtime_data(bob, bob_webdxc.id, "bob -> alice".as_bytes().to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice".as_bytes() {
break;
} else {
panic!(
"Unexpected status update: {}",
String::from_utf8_lossy(&data)
);
}
}
}
// channel is only used to remeber if an advertisement has been sent
// bob for example does not change the channels because he never sends an
// advertisement
assert_eq!(
alice.iroh.get().unwrap().iroh_channels.read().await.len(),
1
);
leave_webxdc_realtime(alice, alice_webxdc.id).await.unwrap();
let topic = get_iroh_topic_for_msg(alice, alice_webxdc.id)
.await
.unwrap();
assert!(if let Some(state) = alice
.iroh
.get()
.unwrap()
.iroh_channels
.read()
.await
.get(&topic)
{
state.subscribe_loop.is_none()
} else {
false
});
}
}

View File

@@ -23,6 +23,7 @@ use crate::peerstate::Peerstate;
use crate::socks::Socks5Config;
use crate::token;
use crate::tools::validate_id;
use iroh_old as iroh;
const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; // yes: uppercase
const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";

View File

@@ -1,11 +1,13 @@
//! Internet Message Format reception pipeline.
use std::collections::HashSet;
use std::str::FromStr;
use anyhow::{Context as _, Result};
use deltachat_contact_tools::{
addr_cmp, may_be_valid_addr, normalize_name, strip_rtlo_characters, ContactAddress,
};
use iroh_gossip::proto::TopicId;
use mailparse::{parse_mail, SingleInfo};
use num_traits::FromPrimitive;
use once_cell::sync::Lazy;
@@ -30,6 +32,7 @@ use crate::message::{
};
use crate::mimeparser::{parse_message_ids, AvatarAction, MimeMessage, SystemMessage};
use crate::param::{Param, Params};
use crate::peer_channels::{get_iroh_topic_for_msg, insert_topic_stub, iroh_add_peer_for_topic};
use crate::peerstate::Peerstate;
use crate::reaction::{set_msg_reaction, Reaction};
use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on_other_device};
@@ -37,9 +40,10 @@ use crate::simplify;
use crate::sql;
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{self, buf_compress, extract_grpid_from_rfc724_mid};
use crate::tools::{self, buf_compress};
use crate::{chatlist_events, location};
use crate::{contact, imap};
use iroh_net::NodeAddr;
/// This is the struct that is returned after receiving one email (aka MIME message).
///
@@ -751,14 +755,21 @@ async fn add_parts(
let state: MessageState;
let mut hidden = false;
let mut needs_delete_job = false;
// if contact renaming is prevented (for mailinglists and bots),
// we use name from From:-header as override name
if prevent_rename {
if let Some(name) = &mime_parser.from.display_name {
for part in &mut mime_parser.parts {
part.param.set(Param::OverrideSenderDisplayname, name);
}
}
}
if mime_parser.incoming {
to_id = ContactId::SELF;
let test_normal_chat = if from_id == ContactId::UNDEFINED {
None
} else {
ChatIdBlocked::lookup_by_contact(context, from_id).await?
};
let test_normal_chat = ChatIdBlocked::lookup_by_contact(context, from_id).await?;
if chat_id.is_none() && mime_parser.delivery_report.is_some() {
chat_id = Some(DC_CHAT_ID_TRASH);
@@ -823,18 +834,13 @@ async fn add_parts(
create_blocked_default
};
if chat_id.is_none() && !is_mdn {
if chat_id.is_none() && (allow_creation || test_normal_chat.is_some()) {
// try to create a group
if let Some((new_chat_id, new_chat_id_blocked)) = create_or_lookup_group(
if let Some((new_chat_id, new_chat_id_blocked)) = create_group(
context,
mime_parser,
is_partial_download.is_some(),
if test_normal_chat.is_none() {
allow_creation
} else {
true
},
create_blocked,
from_id,
to_ids,
@@ -856,7 +862,7 @@ async fn add_parts(
}
}
// In lookup_chat_by_reply() and create_or_lookup_group(), it can happen that the message is put into a chat
// In lookup_chat_by_reply() and create_group(), it can happen that the message is put into a chat
// but the From-address is not a member of this chat.
if let Some(group_chat_id) = chat_id {
if !chat::is_contact_in_chat(context, group_chat_id, from_id).await? {
@@ -914,16 +920,6 @@ async fn add_parts(
apply_mailinglist_changes(context, mime_parser, chat_id).await?;
}
// if contact renaming is prevented (for mailinglists and bots),
// we use name from From:-header as override name
if prevent_rename {
if let Some(name) = &mime_parser.from.display_name {
for part in &mut mime_parser.parts {
part.param.set(Param::OverrideSenderDisplayname, name);
}
}
}
if chat_id.is_none() {
// try to create a normal chat
let create_blocked = if from_id == ContactId::SELF {
@@ -959,7 +955,7 @@ async fn add_parts(
if create_blocked == Blocked::Request && parent.is_some() {
// we do not want any chat to be created implicitly. Because of the origin-scale-up,
// the contact requests will pop up and this should be just fine.
Contact::scaleup_origin_by_id(context, from_id, Origin::IncomingReplyTo)
ContactId::scaleup_origin(context, &[from_id], Origin::IncomingReplyTo)
.await?;
info!(
context,
@@ -1014,7 +1010,6 @@ async fn add_parts(
|| fetching_existing_messages
|| is_mdn
|| is_reaction
|| is_location_kml
|| chat_id_blocked == Blocked::Yes
{
MessageState::InSeen
@@ -1104,12 +1099,11 @@ async fn add_parts(
}
if !to_ids.is_empty() {
if chat_id.is_none() {
if let Some((new_chat_id, new_chat_id_blocked)) = create_or_lookup_group(
if chat_id.is_none() && allow_creation {
if let Some((new_chat_id, new_chat_id_blocked)) = create_group(
context,
mime_parser,
is_partial_download.is_some(),
allow_creation,
Blocked::Not,
from_id,
to_ids,
@@ -1220,7 +1214,7 @@ async fn add_parts(
}
let orig_chat_id = chat_id;
let chat_id = if is_mdn || is_reaction {
let mut chat_id = if is_mdn || is_reaction {
DC_CHAT_ID_TRASH
} else {
chat_id.unwrap_or_else(|| {
@@ -1373,11 +1367,9 @@ async fn add_parts(
let mime_in_reply_to = mime_parser
.get_header(HeaderDef::InReplyTo)
.cloned()
.unwrap_or_default();
let mime_references = mime_parser
.get_header(HeaderDef::References)
.cloned()
.unwrap_or_default();
// fine, so far. now, split the message into simple parts usable as "short messages"
@@ -1432,12 +1424,30 @@ async fn add_parts(
.await?;
}
if let Some(node_addr) = mime_parser.get_header(HeaderDef::IrohNodeAddr) {
match serde_json::from_str::<NodeAddr>(node_addr).context("Failed to parse node address") {
Ok(node_addr) => {
info!(context, "Adding iroh peer with address {node_addr:?}.");
let instance_id = parent.context("Failed to get parent message")?.id;
let node_id = node_addr.node_id;
let relay_server = node_addr.relay_url().map(|relay| relay.as_str());
let topic = get_iroh_topic_for_msg(context, instance_id).await?;
iroh_add_peer_for_topic(context, instance_id, topic, node_id, relay_server).await?;
chat_id = DC_CHAT_ID_TRASH;
}
Err(err) => {
warn!(context, "Couldn't parse NodeAddr: {err:#}.");
}
}
}
for part in &mime_parser.parts {
if part.is_reaction {
let reaction_str = simplify::remove_footers(part.msg.as_str());
set_msg_reaction(
context,
&mime_in_reply_to,
mime_in_reply_to,
orig_chat_id.unwrap_or_default(),
from_id,
sort_timestamp,
@@ -1599,6 +1609,16 @@ RETURNING id
// check all parts whether they contain a new logging webxdc
for (part, msg_id) in mime_parser.parts.iter().zip(&created_db_entries) {
// check if any part contains a webxdc topic id
if part.typ == Viewtype::Webxdc {
if let Some(topic) = mime_parser.get_header(HeaderDef::IrohGossipTopic) {
let topic = TopicId::from_str(topic).context("wrong gossip topic header")?;
insert_topic_stub(context, *msg_id, topic).await?;
} else {
warn!(context, "webxdc doesn't have a gossip topic")
}
}
maybe_set_logging_xdc_inner(
context,
part.typ,
@@ -1795,37 +1815,32 @@ async fn is_probably_private_reply(
Ok(true)
}
/// This function tries to extract the group-id from the message and returns the corresponding
/// chat_id. If the chat does not exist, it is created. If there is no group-id and there are more
/// This function tries to extract the group-id from the message and create a new group
/// chat with this ID. If there is no group-id and there are more
/// than two members, a new ad hoc group is created.
///
/// On success the function returns the found/created (chat_id, chat_blocked) tuple.
#[allow(clippy::too_many_arguments)]
async fn create_or_lookup_group(
async fn create_group(
context: &Context,
mime_parser: &mut MimeMessage,
is_partial_download: bool,
allow_creation: bool,
create_blocked: Blocked,
from_id: ContactId,
to_ids: &[ContactId],
verified_encryption: &VerifiedEncryption,
) -> Result<Option<(ChatId, Blocked)>> {
let grpid = if let Some(grpid) = try_getting_grpid(mime_parser) {
grpid
} else if !allow_creation {
info!(context, "Creating ad-hoc group prevented from caller.");
return Ok(None);
} else if is_partial_download {
// Partial download may be an encrypted message with protected Subject header.
//
// We do not want to create a group with "..." or "Encrypted message" as a subject.
info!(
context,
"Ad-hoc group cannot be created from partial download."
);
return Ok(None);
} else {
let Some(grpid) = mime_parser.get_chat_group_id().map(|s| s.to_string()) else {
if is_partial_download {
// Partial download may be an encrypted message with protected Subject header.
//
// We do not want to create a group with "..." or "Encrypted message" as a subject.
info!(
context,
"Ad-hoc group cannot be created from partial download."
);
return Ok(None);
}
let mut member_ids: Vec<ContactId> = to_ids.to_vec();
if !member_ids.contains(&(from_id)) {
member_ids.push(from_id);
@@ -1841,15 +1856,8 @@ async fn create_or_lookup_group(
return Ok(res);
};
let mut chat_id;
let mut chat_id_blocked;
if let Some((id, _protected, blocked)) = chat::get_chat_id_by_grpid(context, &grpid).await? {
chat_id = Some(id);
chat_id_blocked = blocked;
} else {
chat_id = None;
chat_id_blocked = Default::default();
}
let mut chat_id = None;
let mut chat_id_blocked = Default::default();
// For chat messages, we don't have to guess (is_*probably*_private_reply()) but we know for sure that
// they belong to the group because of the Chat-Group-Id or Message-Id header
@@ -1894,11 +1902,6 @@ async fn create_or_lookup_group(
|| self_explicitly_added(context, &mime_parser).await?)
{
// Group does not exist but should be created.
if !allow_creation {
info!(context, "Creating group forbidden by caller.");
return Ok(None);
}
let grpname = mime_parser
.get_header(HeaderDef::ChatGroupName)
.context("Chat-Group-Name vanished")?
@@ -2461,37 +2464,6 @@ async fn apply_mailinglist_changes(
Ok(())
}
fn try_getting_grpid(mime_parser: &MimeMessage) -> Option<String> {
if let Some(optional_field) = mime_parser.get_chat_group_id() {
return Some(optional_field.clone());
}
// Useful for undecipherable messages sent to known group.
if let Some(extracted_grpid) = extract_grpid(mime_parser, HeaderDef::MessageId) {
return Some(extracted_grpid.to_string());
}
if !mime_parser.has_chat_version() {
if let Some(extracted_grpid) = extract_grpid(mime_parser, HeaderDef::InReplyTo) {
return Some(extracted_grpid.to_string());
} else if let Some(extracted_grpid) = extract_grpid(mime_parser, HeaderDef::References) {
return Some(extracted_grpid.to_string());
}
}
None
}
/// try extract a grpid from a message-id list header value
fn extract_grpid(mime_parser: &MimeMessage, headerdef: HeaderDef) -> Option<&str> {
let header = mime_parser.get_header(headerdef)?;
let parts = header
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty());
parts.filter_map(extract_grpid_from_rfc724_mid).next()
}
/// Creates ad-hoc group and returns chat ID on success.
async fn create_adhoc_group(
context: &Context,

View File

@@ -16,25 +16,6 @@ use crate::imex::{imex, ImexMode};
use crate::test_utils::{get_chat_msg, TestContext, TestContextManager};
use crate::tools::SystemTime;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_grpid_simple() {
let context = TestContext::new_alice().await;
let raw = b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
From: hello@example.org\n\
Subject: outer-subject\n\
In-Reply-To: <lqkjwelq123@123123>\n\
References: <Gr.HcxyMARjyJy.9-uvzWPTLtV@nauta.cu>\n\
\n\
hello\x00";
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
.await
.unwrap();
assert_eq!(mimeparser.incoming, true);
assert_eq!(extract_grpid(&mimeparser, HeaderDef::InReplyTo), None);
let grpid = Some("HcxyMARjyJy");
assert_eq!(extract_grpid(&mimeparser, HeaderDef::References), grpid);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_outgoing() -> Result<()> {
let context = TestContext::new_alice().await;
@@ -61,24 +42,6 @@ async fn test_bad_from() {
assert!(mimeparser.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_grpid_from_multiple() {
let context = TestContext::new_alice().await;
let raw = b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
From: hello@example.org\n\
Subject: outer-subject\n\
In-Reply-To: <Gr.HcxyMARjyJy.9-qweqwe@asd.net>\n\
References: <qweqweqwe>, <Gr.HcxyMARjyJy.9-uvzWPTLtV@nau.ca>\n\
\n\
hello\x00";
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
.await
.unwrap();
let grpid = Some("HcxyMARjyJy");
assert_eq!(extract_grpid(&mimeparser, HeaderDef::InReplyTo), grpid);
assert_eq!(extract_grpid(&mimeparser, HeaderDef::References), grpid);
}
static MSGRMSG: &[u8] =
b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
From: Bob <bob@example.com>\n\
@@ -4552,3 +4515,58 @@ async fn test_list_from() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_receive_vcard() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
for vcard_contains_address in [true, false] {
let mut msg = Message::new(Viewtype::Vcard);
msg.set_file_from_bytes(
&alice,
"claire.vcf",
format!(
"BEGIN:VCARD\n\
VERSION:4.0\n\
FN:Claire\n\
{}\
END:VCARD",
if vcard_contains_address {
"EMAIL;TYPE=work:claire@example.org\n"
} else {
""
}
)
.as_bytes(),
None,
)
.await
.unwrap();
let alice_bob_chat = alice.create_chat(&bob).await;
let sent = alice.send_msg(alice_bob_chat.id, &mut msg).await;
let rcvd = bob.recv_msg(&sent).await;
if vcard_contains_address {
assert_eq!(rcvd.viewtype, Viewtype::Vcard);
} else {
// VCards without an email address are not "deltachat contacts",
// so they are shown as files
assert_eq!(rcvd.viewtype, Viewtype::File);
}
let vcard = tokio::fs::read(rcvd.get_file(&bob).unwrap()).await?;
let vcard = std::str::from_utf8(&vcard)?;
let parsed = deltachat_contact_tools::parse_vcard(vcard);
assert_eq!(parsed.len(), 1);
if vcard_contains_address {
assert_eq!(&parsed[0].addr, "claire@example.org");
} else {
assert_eq!(&parsed[0].addr, "");
}
}
Ok(())
}

View File

@@ -466,6 +466,12 @@ pub async fn convert_folder_meaning(
}
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
ctx.set_config_internal(
Config::IsChatmail,
crate::config::from_bool(session.is_chatmail()),
)
.await?;
// Update quota no more than once a minute.
let quota_needs_update = {
let quota = ctx.quota.read().await;

View File

@@ -65,8 +65,8 @@ pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Resu
let sync_token = token::lookup(context, Namespace::InviteNumber, group)
.await?
.is_none();
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, group).await;
let auth = token::lookup_or_new(context, Namespace::Auth, group).await;
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, group).await?;
let auth = token::lookup_or_new(context, Namespace::Auth, group).await?;
let self_addr = context.get_primary_self_addr().await?;
let self_name = context
.get_config(Config::Displayname)
@@ -294,7 +294,7 @@ pub(crate) async fn handle_securejoin_handshake(
let join_vg = step.starts_with("vg-");
if !matches!(step.as_str(), "vg-request" | "vc-request") {
if !matches!(step, "vg-request" | "vc-request") {
let mut self_found = false;
let self_fingerprint = load_self_public_key(context).await?.fingerprint();
for (addr, key) in &mime_message.gossiped_keys {
@@ -311,7 +311,7 @@ pub(crate) async fn handle_securejoin_handshake(
}
}
match step.as_str() {
match step {
"vg-request" | "vc-request" => {
/*=======================================================
==== Alice - the inviter side ====
@@ -447,7 +447,7 @@ pub(crate) async fn handle_securejoin_handshake(
return Ok(HandshakeMessage::Ignore);
}
contact_id.regossip_keys(context).await?;
Contact::scaleup_origin_by_id(context, contact_id, Origin::SecurejoinInvited).await?;
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
info!(context, "Auth verified.",);
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
inviter_progress(context, contact_id, 600);
@@ -487,7 +487,7 @@ pub(crate) async fn handle_securejoin_handshake(
=======================================================*/
"vc-contact-confirm" => {
if let Some(mut bobstate) = BobState::from_db(&context.sql).await? {
if !bobstate.is_msg_expected(context, step.as_str()) {
if !bobstate.is_msg_expected(context, step) {
warn!(context, "Unexpected vc-contact-confirm.");
return Ok(HandshakeMessage::Ignore);
}
@@ -498,9 +498,7 @@ pub(crate) async fn handle_securejoin_handshake(
Ok(HandshakeMessage::Ignore)
}
"vg-member-added" => {
let Some(member_added) = mime_message
.get_header(HeaderDef::ChatGroupMemberAdded)
.map(|s| s.as_str())
let Some(member_added) = mime_message.get_header(HeaderDef::ChatGroupMemberAdded)
else {
warn!(
context,
@@ -516,7 +514,7 @@ pub(crate) async fn handle_securejoin_handshake(
return Ok(HandshakeMessage::Propagate);
}
if let Some(mut bobstate) = BobState::from_db(&context.sql).await? {
if !bobstate.is_msg_expected(context, step.as_str()) {
if !bobstate.is_msg_expected(context, step) {
warn!(context, "Unexpected vg-member-added.");
return Ok(HandshakeMessage::Propagate);
}
@@ -571,7 +569,7 @@ pub(crate) async fn observe_securejoin_on_other_device(
info!(context, "Observing secure-join message {step:?}.");
if !matches!(
step.as_str(),
step,
"vg-request-with-auth" | "vc-request-with-auth" | "vg-member-added" | "vc-contact-confirm"
) {
return Ok(HandshakeMessage::Ignore);
@@ -642,21 +640,21 @@ pub(crate) async fn observe_securejoin_on_other_device(
ChatId::set_protection_for_contact(context, contact_id, mime_message.timestamp_sent).await?;
if step.as_str() == "vg-member-added" {
if step == "vg-member-added" {
inviter_progress(context, contact_id, 800);
}
if step.as_str() == "vg-member-added" || step.as_str() == "vc-contact-confirm" {
if step == "vg-member-added" || step == "vc-contact-confirm" {
inviter_progress(context, contact_id, 1000);
}
if step.as_str() == "vg-request-with-auth" || step.as_str() == "vc-request-with-auth" {
if step == "vg-request-with-auth" || step == "vc-request-with-auth" {
// This actually reflects what happens on the first device (which does the secure
// join) and causes a subsequent "vg-member-added" message to create an unblocked
// verified group.
ChatId::create_for_contact_with_blocked(context, contact_id, Blocked::Not).await?;
}
if step.as_str() == "vg-member-added" {
if step == "vg-member-added" {
Ok(HandshakeMessage::Propagate)
} else {
Ok(HandshakeMessage::Ignore)

View File

@@ -14,7 +14,7 @@ use super::qrinvite::QrInvite;
use super::{encrypted_and_signed, verify_sender_by_fingerprint};
use crate::chat::{self, ChatId};
use crate::config::Config;
use crate::contact::{Contact, Origin};
use crate::contact::{ContactId, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::HeaderDef;
@@ -261,7 +261,7 @@ impl BobState {
return Ok(None);
}
};
if !self.is_msg_expected(context, step.as_str()) {
if !self.is_msg_expected(context, step) {
info!(context, "{} message out of sync for BobState", step);
return Ok(None);
}
@@ -326,8 +326,12 @@ impl BobState {
Some(context.get_config_i64(Config::KeyId).await?).filter(|&id| id > 0);
peerstate.save_to_db(&context.sql).await?;
Contact::scaleup_origin_by_id(context, self.invite.contact_id(), Origin::SecurejoinJoined)
.await?;
ContactId::scaleup_origin(
context,
&[self.invite.contact_id()],
Origin::SecurejoinJoined,
)
.await?;
context.emit_event(EventType::ContactsChanged(None));
self.update_next(&context.sql, SecureJoinStep::Completed)

View File

@@ -671,10 +671,9 @@ impl Sql {
/// `passphrase` is the SQLCipher database passphrase.
/// Empty string if database is not encrypted.
fn new_connection(path: &Path, passphrase: &str) -> Result<Connection> {
let mut flags = OpenFlags::SQLITE_OPEN_NO_MUTEX;
flags.insert(OpenFlags::SQLITE_OPEN_READ_WRITE);
flags.insert(OpenFlags::SQLITE_OPEN_CREATE);
let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
| OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE;
let conn = Connection::open_with_flags(path, flags)?;
conn.execute_batch(
"PRAGMA cipher_memory_security = OFF; -- Too slow on Android

View File

@@ -137,9 +137,9 @@ ALTER TABLE acpeerstates ADD COLUMN gossip_key;"#,
// the current ones are defined by chats.blocked=2
sql.execute_migration(
r#"
DELETE FROM msgs WHERE chat_id=1 OR chat_id=2;"
CREATE INDEX chats_contacts_index2 ON chats_contacts (contact_id);"
ALTER TABLE msgs ADD COLUMN timestamp_sent INTEGER DEFAULT 0;")
DELETE FROM msgs WHERE chat_id=1 OR chat_id=2;
CREATE INDEX chats_contacts_index2 ON chats_contacts (contact_id);
ALTER TABLE msgs ADD COLUMN timestamp_sent INTEGER DEFAULT 0;
ALTER TABLE msgs ADD COLUMN timestamp_rcvd INTEGER DEFAULT 0;"#,
27,
)
@@ -267,7 +267,7 @@ CREATE INDEX msgs_index6 ON msgs (location_id);"#,
// so, msg_id may or may not exist.
sql.execute_migration(
r#"
CREATE TABLE devmsglabels (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT, msg_id INTEGER DEFAULT 0);",
CREATE TABLE devmsglabels (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT, msg_id INTEGER DEFAULT 0);
CREATE INDEX devmsglabels_index1 ON devmsglabels (label);"#, 59)
.await?;
if exists_before_update && sql.get_raw_config_int("bcc_self").await?.is_none() {
@@ -912,6 +912,30 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
.await?;
}
if dbversion < 111 {
sql.execute_migration(
"CREATE TABLE iroh_gossip_peers (msg_id TEXT not NULL, topic TEXT NOT NULL, public_key TEXT NOT NULL)",
111,
)
.await?;
}
if dbversion < 112 {
sql.execute_migration(
"DROP TABLE iroh_gossip_peers; CREATE TABLE iroh_gossip_peers (msg_id INTEGER not NULL, topic BLOB NOT NULL, public_key BLOB NOT NULL, relay_server TEXT, UNIQUE (public_key, topic)) STRICT",
112,
)
.await?;
}
if dbversion < 113 {
sql.execute_migration(
"DROP TABLE iroh_gossip_peers; CREATE TABLE iroh_gossip_peers (msg_id INTEGER not NULL, topic BLOB NOT NULL, public_key BLOB NOT NULL, relay_server TEXT, UNIQUE (topic, public_key), PRIMARY KEY(topic, public_key)) STRICT",
113,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?

View File

@@ -443,6 +443,9 @@ pub enum StockMessage {
fallback = "Could not yet establish guaranteed end-to-end encryption, but you may already send a message."
))]
SecurejoinWaitTimeout = 191,
#[strum(props(fallback = "Contact"))]
Contact = 200,
}
impl StockMessage {
@@ -1098,6 +1101,11 @@ pub(crate) async fn videochat_invite_msg_body(context: &Context, url: &str) -> S
.replace1(url)
}
/// Stock string: `Contact`.
pub(crate) async fn contact(context: &Context) -> String {
translated(context, StockMessage::Contact).await
}
/// Stock string: `Error:\n\n“%1$s”`.
pub(crate) async fn configuration_failed(context: &Context, details: &str) -> String {
translated(context, StockMessage::ConfigurationFailed)

View File

@@ -2,6 +2,7 @@
use std::borrow::Cow;
use std::fmt;
use std::str;
use crate::chat::Chat;
use crate::constants::Chattype;
@@ -228,6 +229,12 @@ impl Message {
);
append_text = true;
}
Viewtype::Vcard => {
emoji = Some("👤");
type_name = Some(stock_str::contact(context).await);
type_file = None;
append_text = true;
}
Viewtype::Text | Viewtype::Unknown => {
emoji = None;
if self.param.get_cmd() == SystemMessage::LocationOnly {
@@ -340,10 +347,6 @@ mod tests {
msg.set_file("foo.mp3", None);
assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio
let mut msg = Message::new(Viewtype::Audio);
msg.set_file("foo.mp3", None);
assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio, empty text is not added
let mut msg = Message::new(Viewtype::Audio);
msg.set_text(some_text.clone());
msg.set_file("foo.mp3", None);
@@ -363,6 +366,27 @@ mod tests {
msg.set_file("foo.bar", None);
assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations
let mut msg = Message::new(Viewtype::Vcard);
msg.set_file("foo.vcf", None);
assert_summary_texts(&msg, ctx, "👤 Contact").await;
msg.set_text(some_text.clone());
assert_summary_texts(&msg, ctx, "👤 bla bla").await;
let mut msg = Message::new(Viewtype::Vcard);
msg.set_file_from_bytes(
ctx,
"alice.vcf",
b"BEGIN:VCARD\n\
VERSION:4.0\n\
FN:Alice Wonderland\n\
EMAIL;TYPE=work:alice@example.org\n\
END:VCARD",
None,
)
.await
.unwrap();
assert_summary_texts(&msg, ctx, "👤 Contact").await;
// Forwarded
let mut msg = Message::new(Viewtype::Text);
msg.set_text(some_text.clone());

View File

@@ -93,14 +93,14 @@ pub async fn lookup_or_new(
context: &Context,
namespace: Namespace,
foreign_id: Option<ChatId>,
) -> String {
if let Ok(Some(token)) = lookup(context, namespace, foreign_id).await {
return token;
) -> Result<String> {
if let Some(token) = lookup(context, namespace, foreign_id).await? {
return Ok(token);
}
let token = create_id();
save(context, namespace, foreign_id, &token).await.ok();
token
save(context, namespace, foreign_id, &token).await?;
Ok(token)
}
pub async fn exists(context: &Context, namespace: Namespace, token: &str) -> Result<bool> {

View File

@@ -294,31 +294,6 @@ pub(crate) fn create_outgoing_rfc724_mid() -> String {
format!("Mr.{}.{}@localhost", create_id(), create_id())
}
/// Extract the group id (grpid) from a message id (mid)
///
/// # Arguments
///
/// * `mid` - A string that holds the message id. Leading/Trailing <>
/// characters are automatically stripped.
pub(crate) fn extract_grpid_from_rfc724_mid(mid: &str) -> Option<&str> {
let mid = mid.trim_start_matches('<').trim_end_matches('>');
if mid.len() < 9 || !mid.starts_with("Gr.") {
return None;
}
if let Some(mid_without_offset) = mid.get(3..) {
if let Some(grpid_len) = mid_without_offset.find('.') {
/* strict length comparison, the 'Gr.' magic is weak enough */
if grpid_len == 11 || grpid_len == 16 {
return Some(mid_without_offset.get(0..grpid_len).unwrap());
}
}
}
None
}
// the returned suffix is lower-case
pub fn get_filesuffix_lc(path_filename: &str) -> Option<String> {
Path::new(path_filename)
@@ -925,45 +900,11 @@ DKIM Results: Passed=true";
}
}
#[test]
fn test_extract_grpid_from_rfc724_mid() {
// Should return None if we pass invalid mid
let mid = "foobar";
let grpid = extract_grpid_from_rfc724_mid(mid);
assert_eq!(grpid, None);
// Should return None if grpid has a length which is not 11 or 16
let mid = "Gr.12345678.morerandom@domain.de";
let grpid = extract_grpid_from_rfc724_mid(mid);
assert_eq!(grpid, None);
// Should return extracted grpid for grpid with length of 11
let mid = "Gr.12345678901.morerandom@domain.de";
let grpid = extract_grpid_from_rfc724_mid(mid);
assert_eq!(grpid, Some("12345678901"));
// Should return extracted grpid for grpid with length of 11
let mid = "Gr.1234567890123456.morerandom@domain.de";
let grpid = extract_grpid_from_rfc724_mid(mid);
assert_eq!(grpid, Some("1234567890123456"));
// Should return extracted grpid for grpid with length of 11
let mid = "<Gr.12345678901.morerandom@domain.de>";
let grpid = extract_grpid_from_rfc724_mid(mid);
assert_eq!(grpid, Some("12345678901"));
// Should return extracted grpid for grpid with length of 11
let mid = "<Gr.1234567890123456.morerandom@domain.de>";
let grpid = extract_grpid_from_rfc724_mid(mid);
assert_eq!(grpid, Some("1234567890123456"));
}
#[test]
fn test_create_outgoing_rfc724_mid() {
let mid = create_outgoing_rfc724_mid();
assert!(mid.starts_with("Mr."));
assert!(mid.ends_with("@localhost"));
assert!(extract_grpid_from_rfc724_mid(mid.as_str()).is_none());
}
proptest! {