Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e35af8c99 | ||
|
|
1c75dd5681 |
@@ -41,15 +41,10 @@ jobs:
|
|||||||
SCCACHE_DIR: /var/local/sccache
|
SCCACHE_DIR: /var/local/sccache
|
||||||
SCCACHE_SERVER_PORT: "4228"
|
SCCACHE_SERVER_PORT: "4228"
|
||||||
steps:
|
steps:
|
||||||
# Plain git: the aarch64 host runner has no Node.js for
|
# v4 built-in checkout: native Go, needs no Node (the aarch64 host runner
|
||||||
# actions/checkout (same as uhhm/corp).
|
# has none) and keeps the token out of a fetch URL.
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
uses: builtin:checkout
|
||||||
git init -q .
|
|
||||||
git fetch -q --depth 1 "https://ci:${{ gitea.token }}@${GITEA_SERVER#https://}/${{ gitea.repository }}.git" "${{ gitea.sha }}"
|
|
||||||
git checkout -q FETCH_HEAD
|
|
||||||
env:
|
|
||||||
GITEA_SERVER: ${{ gitea.server_url }}
|
|
||||||
|
|
||||||
- name: Check tag matches pkgver
|
- name: Check tag matches pkgver
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 (2026-09-24)
|
||||||
|
|
||||||
|
- The way back in: with a mailbox configured (`JMAP_URL`, `JMAP_TOKEN`, `MAIL_REPLY_DOMAIN`), every mail about a record carries a Reply-To of its own, `case+<bucket>.<record>@<domain>`, and replies to it are read over JMAP and published on `portal.mail.received` for portal to append as notes. The record id in the address is portal's own unguessable chain hash, and portal checks the sender against the address the record holds, so the address alone opens nothing.
|
||||||
|
- A reply is read for what the person wrote: the quoted history, the signature and the attribution line are cut off, and an HTML-only mail falls back to its preview. Nothing is marked seen until portal has it.
|
||||||
|
- Unconfigured, gdo sends exactly as before and never reads anything.
|
||||||
|
|
||||||
## 0.1.2 (2026-09-23)
|
## 0.1.2 (2026-09-23)
|
||||||
|
|
||||||
- The stream is `mail`, not shouted; matches portal 0.3.44. The unit runs the packaged binary at /usr/bin/gdo.
|
- The stream is `mail`, not shouted; matches portal 0.3.44. The unit runs the packaged binary at /usr/bin/gdo.
|
||||||
|
|||||||
Generated
+628
-16
@@ -11,6 +11,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
@@ -32,7 +41,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand",
|
"rand 0.8.8",
|
||||||
"regex",
|
"regex",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-native-certs 0.7.3",
|
"rustls-native-certs 0.7.3",
|
||||||
@@ -42,7 +51,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_nanos",
|
"serde_nanos",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -64,6 +73,18 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -97,6 +118,12 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -122,6 +149,36 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
|
"num-traits",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -163,6 +220,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -180,7 +246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
"digest",
|
"digest",
|
||||||
"fiat-crypto",
|
"fiat-crypto",
|
||||||
@@ -411,12 +477,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gdo"
|
name = "gdo"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"lettre",
|
"lettre",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -441,8 +509,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -466,6 +550,29 @@ dependencies = [
|
|||||||
"itoa",
|
"itoa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -478,6 +585,90 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
"want",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.23.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -582,12 +773,29 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnet"
|
||||||
|
version = "2.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.105"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -637,6 +845,12 @@ version = "0.4.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -678,9 +892,9 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"ed25519",
|
"ed25519",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand 0.8.8",
|
||||||
"signatory",
|
"signatory",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -708,7 +922,7 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand",
|
"rand 0.8.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -717,6 +931,15 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -825,6 +1048,62 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.10.3",
|
||||||
|
"rand_pcg",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -840,6 +1119,12 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.8"
|
version = "0.8.8"
|
||||||
@@ -848,7 +1133,18 @@ checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -858,7 +1154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -867,7 +1163,22 @@ version = "0.6.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -899,6 +1210,44 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -907,12 +1256,18 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
"libc",
|
"libc",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -976,6 +1331,7 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1001,6 +1357,18 @@ dependencies = [
|
|||||||
"untrusted",
|
"untrusted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@@ -1115,6 +1483,18 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -1122,7 +1502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1158,7 +1538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -1170,7 +1550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1239,6 +1619,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -1256,7 +1645,16 @@ version = "1.0.69"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1270,6 +1668,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@@ -1319,6 +1728,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
@@ -1382,7 +1797,7 @@ dependencies = [
|
|||||||
"futures-sink",
|
"futures-sink",
|
||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"rand",
|
"rand 0.8.8",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-native-certs 0.8.4",
|
"rustls-native-certs 0.8.4",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -1391,6 +1806,51 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -1452,6 +1912,12 @@ dependencies = [
|
|||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "try-lock"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tryhard"
|
name = "tryhard"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -1510,18 +1976,164 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "want"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||||
|
dependencies = [
|
||||||
|
"try-lock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.78"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.105"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gdo"
|
name = "gdo"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Sends the code that opens the iris: delivers portal's outgoing mail from JetStream to the host's SMTP"
|
description = "Sends the code that opens the iris: delivers portal's outgoing mail from JetStream to the host's SMTP"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -14,6 +14,8 @@ lettre = { version = "0.11", default-features = false, features = ["builder", "s
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "process", "fs"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "process", "fs"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# project.uhhm.no (namespace bl). pkgver is the version; the tag has
|
# project.uhhm.no (namespace bl). pkgver is the version; the tag has
|
||||||
# to agree with it.
|
# to agree with it.
|
||||||
pkgname=gdo
|
pkgname=gdo
|
||||||
pkgver=0.1.2
|
pkgver=0.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Sends the code that opens the iris: delivers portal's outgoing mail from JetStream to the host's SMTP"
|
pkgdesc="Sends the code that opens the iris: delivers portal's outgoing mail from JetStream to the host's SMTP"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
@@ -84,3 +84,30 @@ A minimal theme:
|
|||||||
#v(3mm)
|
#v(3mm)
|
||||||
#sys.inputs.at("text", default: "")
|
#sys.inputs.at("text", default: "")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## The way back in
|
||||||
|
|
||||||
|
With a mailbox configured, every mail about a record carries a
|
||||||
|
Reply-To of its own and replies come back to the case:
|
||||||
|
|
||||||
|
| Env | |
|
||||||
|
|---|---|
|
||||||
|
| `JMAP_URL` | the session endpoint, e.g. `https://mail.example.no/.well-known/jmap` |
|
||||||
|
| `JMAP_TOKEN` | a bearer token for that account |
|
||||||
|
| `MAIL_REPLY_DOMAIN` | the domain the reply addresses live on |
|
||||||
|
| `MAIL_REPLY_PREFIX` | the local part before the `+`, `case` by default |
|
||||||
|
| `JMAP_EVERY` | seconds between looks, 60 by default |
|
||||||
|
|
||||||
|
All three of the first are needed or inbound stays off: a reply
|
||||||
|
address nobody reads is worse than none. The address is
|
||||||
|
`case+<bucket>.<record>@<domain>`, and the record id in it is
|
||||||
|
portal's own chain hash - the same unguessable capability a decide
|
||||||
|
link carries. It is half the proof: portal also checks the sender
|
||||||
|
against the address the record holds, and a reply is only ever a
|
||||||
|
note, never a decision.
|
||||||
|
|
||||||
|
gdo reads what is unread, publishes each reply on
|
||||||
|
`portal.mail.received`, and marks it seen only once portal has it - a
|
||||||
|
crash in between costs a repeated note, which portal refuses by id,
|
||||||
|
rather than a lost one. Mail addressed to anything but a record is
|
||||||
|
left unread for a person.
|
||||||
|
|||||||
@@ -6,5 +6,9 @@ SMTP_PORT=25
|
|||||||
# What gdo says in EHLO; a strict server refuses a bare hostname.
|
# What gdo says in EHLO; a strict server refuses a bare hostname.
|
||||||
# Unset, the host's full name is used.
|
# Unset, the host's full name is used.
|
||||||
#SMTP_HELO=host.example.no
|
#SMTP_HELO=host.example.no
|
||||||
|
# Replies to case mail, read over JMAP (all three, or inbound stays off).
|
||||||
|
#JMAP_URL=https://mail.example.no/.well-known/jmap
|
||||||
|
#JMAP_TOKEN=
|
||||||
|
#MAIL_REPLY_DOMAIN=post.example.no
|
||||||
# A .typ file: set, every mail carries a PDF rendered from it.
|
# A .typ file: set, every mail carries a PDF rendered from it.
|
||||||
#GDO_TYPST_THEME=/etc/gdo/theme.typ
|
#GDO_TYPST_THEME=/etc/gdo/theme.typ
|
||||||
|
|||||||
+469
@@ -0,0 +1,469 @@
|
|||||||
|
//! The way back in: replies to case mail, read over JMAP and handed
|
||||||
|
//! to portal as notes.
|
||||||
|
//!
|
||||||
|
//! Every case mail gdo sends carries a Reply-To of its own
|
||||||
|
//! (`case+<bucket>.<record>@<domain>`). The record id in it is the
|
||||||
|
//! same unguessable chain hash portal already treats as a capability
|
||||||
|
//! in a decide link, so an address is proof of having been sent that
|
||||||
|
//! mail. It is only half the proof: portal also checks the sender
|
||||||
|
//! against the address the record holds, and a reply never moves a
|
||||||
|
//! case. This module does no judging of its own - it reads, addresses
|
||||||
|
//! and forwards.
|
||||||
|
//!
|
||||||
|
//! Unset `JMAP_URL` means gdo sends and never reads, which is where
|
||||||
|
//! every host starts.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Portal's `mail::Incoming`, v1. Same shape, same subject.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Incoming {
|
||||||
|
pub v: u8,
|
||||||
|
pub id: String,
|
||||||
|
pub bucket: String,
|
||||||
|
pub record: String,
|
||||||
|
pub from: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subject: String,
|
||||||
|
pub text: String,
|
||||||
|
pub received_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const RECEIVED_SUBJECT: &str = "portal.mail.received";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Inbox {
|
||||||
|
/// The JMAP session endpoint, e.g. `https://mail.example.no/.well-known/jmap`.
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
/// The domain the reply addresses live on, e.g. `post.example.no`.
|
||||||
|
pub domain: String,
|
||||||
|
/// The local part before the `+`, `case` unless told otherwise.
|
||||||
|
pub prefix: String,
|
||||||
|
/// How often to look, in seconds.
|
||||||
|
pub every: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inbox {
|
||||||
|
pub fn from_env() -> Option<Inbox> {
|
||||||
|
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
|
||||||
|
Some(Inbox {
|
||||||
|
url: var("JMAP_URL")?,
|
||||||
|
token: var("JMAP_TOKEN")?,
|
||||||
|
domain: var("MAIL_REPLY_DOMAIN")?,
|
||||||
|
prefix: var("MAIL_REPLY_PREFIX").unwrap_or_else(|| "case".to_string()),
|
||||||
|
every: var("JMAP_EVERY").and_then(|v| v.parse().ok()).unwrap_or(60),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a reply to this mail should land. `None` for anything
|
||||||
|
/// that is not a record's mail (an invite, say), which therefore
|
||||||
|
/// keeps whatever Reply-To the site gave it.
|
||||||
|
pub fn reply_address(&self, mail_id: &str) -> Option<String> {
|
||||||
|
let (bucket, record) = split_id(mail_id)?;
|
||||||
|
Some(format!("{}+{bucket}.{record}@{}", self.prefix, self.domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The record a delivered-to address names, if it is one of ours.
|
||||||
|
pub fn record_for(&self, address: &str) -> Option<(String, String)> {
|
||||||
|
let address = address.trim().trim_start_matches('<').trim_end_matches('>').to_lowercase();
|
||||||
|
let (local, domain) = address.split_once('@')?;
|
||||||
|
if domain != self.domain.to_lowercase() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (prefix, tail) = local.split_once('+')?;
|
||||||
|
if prefix != self.prefix.to_lowercase() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (bucket, record) = tail.split_once('.')?;
|
||||||
|
let plain = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
|
||||||
|
(plain(bucket) && plain(record)).then(|| (bucket.to_string(), record.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<bucket>:<record>:<state>` is a record's mail; anything else
|
||||||
|
/// (`invite:...`, a probe) is not.
|
||||||
|
fn split_id(mail_id: &str) -> Option<(String, String)> {
|
||||||
|
let parts: Vec<&str> = mail_id.split(':').collect();
|
||||||
|
if parts.len() != 3 || parts[0] == "invite" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let plain = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
|
||||||
|
(plain(parts[0]) && plain(parts[1])).then(|| (parts[0].to_string(), parts[1].to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a person actually wrote, with the quoted history below it cut
|
||||||
|
/// off. Mail clients mark the quote in ways that are conventions
|
||||||
|
/// rather than rules, so this takes the common ones and errs towards
|
||||||
|
/// keeping text: a note with too much in it is a nuisance, a note
|
||||||
|
/// with the answer cut out of it is a bug.
|
||||||
|
pub fn spoken_part(body: &str) -> String {
|
||||||
|
let mut kept: Vec<&str> = Vec::new();
|
||||||
|
for line in body.lines() {
|
||||||
|
let t = line.trim();
|
||||||
|
// "On <date> <someone> wrote:" and its Norwegian twin, then
|
||||||
|
// the usual separators.
|
||||||
|
let attribution = (t.starts_with("On ") || t.starts_with("Den ")) && t.ends_with(':') && (t.contains("wrote") || t.contains("skrev"));
|
||||||
|
if attribution || t == "-----Original Message-----" || t == "--" || t.starts_with("________") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if t.starts_with('>') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
kept.push(line);
|
||||||
|
}
|
||||||
|
while kept.last().is_some_and(|l| l.trim().is_empty()) {
|
||||||
|
kept.pop();
|
||||||
|
}
|
||||||
|
kept.join("\n").trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JMAP, the little of it this needs -------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Session {
|
||||||
|
#[serde(rename = "apiUrl")]
|
||||||
|
api_url: String,
|
||||||
|
#[serde(rename = "primaryAccounts")]
|
||||||
|
primary_accounts: std::collections::HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
fn mail_account(&self) -> Option<&String> {
|
||||||
|
self.primary_accounts.get("urn:ietf:params:jmap:mail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Envelope {
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One mail as JMAP hands it over. Only the parts a note is made of.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Mail {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub subject: Option<String>,
|
||||||
|
#[serde(default, rename = "receivedAt")]
|
||||||
|
pub received_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
from: Option<Vec<Envelope>>,
|
||||||
|
#[serde(default)]
|
||||||
|
to: Option<Vec<Envelope>>,
|
||||||
|
#[serde(default)]
|
||||||
|
cc: Option<Vec<Envelope>>,
|
||||||
|
#[serde(default, rename = "preview")]
|
||||||
|
preview: Option<String>,
|
||||||
|
#[serde(default, rename = "bodyValues")]
|
||||||
|
body_values: Option<std::collections::HashMap<String, BodyValue>>,
|
||||||
|
#[serde(default, rename = "textBody")]
|
||||||
|
text_body: Option<Vec<BodyPart>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BodyValue {
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BodyPart {
|
||||||
|
#[serde(rename = "partId")]
|
||||||
|
part_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mail {
|
||||||
|
pub fn sender(&self) -> String {
|
||||||
|
match self.from.as_ref().and_then(|f| f.first()) {
|
||||||
|
Some(e) => match &e.name {
|
||||||
|
Some(name) => format!("{name} <{}>", e.email),
|
||||||
|
None => e.email.clone(),
|
||||||
|
},
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every address this mail was delivered to, which is where the
|
||||||
|
/// record's own address will be.
|
||||||
|
pub fn recipients(&self) -> Vec<String> {
|
||||||
|
self.to
|
||||||
|
.iter()
|
||||||
|
.chain(self.cc.iter())
|
||||||
|
.flat_map(|v| v.iter())
|
||||||
|
.map(|e| e.email.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plain-text body, or the preview when the mail carried only
|
||||||
|
/// HTML - a short note is better than none.
|
||||||
|
pub fn text(&self) -> String {
|
||||||
|
let body = self
|
||||||
|
.text_body
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|parts| parts.first())
|
||||||
|
.and_then(|p| p.part_id.as_ref())
|
||||||
|
.and_then(|id| self.body_values.as_ref()?.get(id))
|
||||||
|
.map(|b| b.value.clone())
|
||||||
|
.or_else(|| self.preview.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
spoken_part(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn received_ms(&self) -> i64 {
|
||||||
|
self.received_at
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
||||||
|
.map(|t| t.timestamp_millis())
|
||||||
|
.unwrap_or_else(|| chrono::Utc::now().timestamp_millis())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reply, addressed to its record, or why it is not one.
|
||||||
|
pub fn as_incoming(&self, inbox: &Inbox) -> Option<Incoming> {
|
||||||
|
let (bucket, record) = self.recipients().iter().find_map(|a| inbox.record_for(a))?;
|
||||||
|
let text = self.text();
|
||||||
|
if text.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Incoming {
|
||||||
|
v: CONTRACT,
|
||||||
|
id: format!("jmap:{}", self.id),
|
||||||
|
bucket,
|
||||||
|
record,
|
||||||
|
from: self.sender(),
|
||||||
|
subject: self.subject.clone().unwrap_or_default(),
|
||||||
|
text,
|
||||||
|
received_ms: self.received_ms(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTRACT: u8 = 1;
|
||||||
|
|
||||||
|
/// Reads what is unread, hands each reply to portal, and marks it
|
||||||
|
/// seen only once portal has it - a crash between the two costs a
|
||||||
|
/// repeated note, which portal refuses by id, not a lost one.
|
||||||
|
pub async fn sweep(inbox: &Inbox, nats: &async_nats::Client, http: &reqwest::Client) -> anyhow::Result<usize> {
|
||||||
|
let session: Session = http
|
||||||
|
.get(&inbox.url)
|
||||||
|
.bearer_auth(&inbox.token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("jmap session")?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("jmap session is not json")?;
|
||||||
|
let account = session.mail_account().ok_or_else(|| anyhow!("this jmap account has no mail"))?.clone();
|
||||||
|
|
||||||
|
let request = serde_json::json!({
|
||||||
|
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||||
|
"methodCalls": [
|
||||||
|
["Email/query", {
|
||||||
|
"accountId": account,
|
||||||
|
"filter": { "hasKeyword": "$draft" , "operator": "NOT" },
|
||||||
|
"limit": 50,
|
||||||
|
}, "q"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// The filter above is written the long way round below: JMAP's
|
||||||
|
// NOT takes conditions, and "unread" is the absence of $seen.
|
||||||
|
let request = serde_json::json!({
|
||||||
|
"using": request["using"].clone(),
|
||||||
|
"methodCalls": [
|
||||||
|
["Email/query", {
|
||||||
|
"accountId": account,
|
||||||
|
"filter": { "operator": "NOT", "conditions": [{ "hasKeyword": "$seen" }] },
|
||||||
|
"limit": 50,
|
||||||
|
}, "q"],
|
||||||
|
["Email/get", {
|
||||||
|
"accountId": account,
|
||||||
|
"#ids": { "resultOf": "q", "name": "Email/query", "path": "/ids" },
|
||||||
|
"properties": ["id", "subject", "from", "to", "cc", "receivedAt", "preview", "textBody", "bodyValues"],
|
||||||
|
"fetchTextBodyValues": true,
|
||||||
|
}, "g"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let response: serde_json::Value = http
|
||||||
|
.post(&session.api_url)
|
||||||
|
.bearer_auth(&inbox.token)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("jmap request")?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("jmap answer is not json")?;
|
||||||
|
|
||||||
|
let mails: Vec<Mail> = response["methodResponses"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|calls| calls.iter().find(|c| c[2] == "g"))
|
||||||
|
.and_then(|c| c[1]["list"].clone().into())
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()
|
||||||
|
.context("jmap Email/get list")?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut sent = 0;
|
||||||
|
let mut seen: Vec<String> = Vec::new();
|
||||||
|
for mail in &mails {
|
||||||
|
let Some(incoming) = mail.as_incoming(inbox) else {
|
||||||
|
// Not addressed to a record: left unread for a person.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
nats.publish(RECEIVED_SUBJECT, serde_json::to_vec(&incoming)?.into()).await?;
|
||||||
|
nats.flush().await?;
|
||||||
|
seen.push(mail.id.clone());
|
||||||
|
sent += 1;
|
||||||
|
}
|
||||||
|
if !seen.is_empty() {
|
||||||
|
let update: serde_json::Map<String, serde_json::Value> = seen
|
||||||
|
.iter()
|
||||||
|
.map(|id| (id.clone(), serde_json::json!({ "keywords/$seen": true })))
|
||||||
|
.collect();
|
||||||
|
let mark = serde_json::json!({
|
||||||
|
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||||
|
"methodCalls": [["Email/set", { "accountId": account, "update": update }, "s"]],
|
||||||
|
});
|
||||||
|
http.post(&session.api_url).bearer_auth(&inbox.token).json(&mark).send().await?.error_for_status()?;
|
||||||
|
}
|
||||||
|
Ok(sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn inbox() -> Inbox {
|
||||||
|
Inbox {
|
||||||
|
url: "https://mail.example.no/.well-known/jmap".into(),
|
||||||
|
token: "t".into(),
|
||||||
|
domain: "post.example.no".into(),
|
||||||
|
prefix: "case".into(),
|
||||||
|
every: 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_records_mail_gets_an_address_of_its_own() {
|
||||||
|
assert_eq!(
|
||||||
|
inbox().reply_address("hazards:a1b2c3:open").as_deref(),
|
||||||
|
Some("case+hazards.a1b2c3@post.example.no")
|
||||||
|
);
|
||||||
|
// An invite is not a case, and neither is a probe.
|
||||||
|
assert_eq!(inbox().reply_address("invite:kari:board:17"), None);
|
||||||
|
assert_eq!(inbox().reply_address("probe:17"), None);
|
||||||
|
assert_eq!(inbox().reply_address("a:b:c:d"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_address_names_its_record_or_nothing() {
|
||||||
|
let i = inbox();
|
||||||
|
assert_eq!(i.record_for("case+hazards.a1b2c3@post.example.no"), Some(("hazards".into(), "a1b2c3".into())));
|
||||||
|
// Case and angle brackets are how mail servers write it.
|
||||||
|
assert_eq!(i.record_for("<Case+Hazards.A1B2C3@Post.Example.No>"), Some(("hazards".into(), "a1b2c3".into())));
|
||||||
|
for not_ours in [
|
||||||
|
"post@post.example.no",
|
||||||
|
"case+hazards.a1b2c3@somewhere.else",
|
||||||
|
"other+hazards.a1b2c3@post.example.no",
|
||||||
|
"case+hazards@post.example.no",
|
||||||
|
"case+haz/ards.a1b2@post.example.no",
|
||||||
|
"case+.a1b2@post.example.no",
|
||||||
|
] {
|
||||||
|
assert_eq!(i.record_for(not_ours), None, "{not_ours}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reply_keeps_what_was_written_and_drops_what_was_quoted() {
|
||||||
|
let body = "It is fixed now.\n\nThanks.\n\nOn Tuesday Tomter Vel wrote:\n> We have your report\n> and will look at it";
|
||||||
|
assert_eq!(spoken_part(body), "It is fixed now.\n\nThanks.");
|
||||||
|
assert_eq!(spoken_part("Ordnet.\n\nDen 3. mars skrev Tomter Vel:\n> hei"), "Ordnet.");
|
||||||
|
assert_eq!(spoken_part("Short one.\n--\nKari"), "Short one.");
|
||||||
|
assert_eq!(spoken_part("Top.\n________________________________\nFrom: someone"), "Top.");
|
||||||
|
// A reply with nothing but a quote has nothing to say.
|
||||||
|
assert_eq!(spoken_part("> only a quote"), "");
|
||||||
|
// Text that merely mentions writing is not an attribution.
|
||||||
|
assert_eq!(spoken_part("I wrote to you yesterday about this"), "I wrote to you yesterday about this");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mail_json(to: &str, text: &str) -> Mail {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "M1",
|
||||||
|
"subject": "Re: We have your report",
|
||||||
|
"receivedAt": "2026-09-24T10:00:00Z",
|
||||||
|
"from": [{ "name": "Kari", "email": "kari@x.no" }],
|
||||||
|
"to": [{ "email": to }],
|
||||||
|
"textBody": [{ "partId": "1" }],
|
||||||
|
"bodyValues": { "1": { "value": text } },
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mail_to_a_records_address_becomes_a_reply_for_portal() {
|
||||||
|
let incoming = mail_json("case+hazards.a1b2c3@post.example.no", "It is fixed.\n> quoted")
|
||||||
|
.as_incoming(&inbox())
|
||||||
|
.expect("addressed to a record");
|
||||||
|
assert_eq!(incoming.bucket, "hazards");
|
||||||
|
assert_eq!(incoming.record, "a1b2c3");
|
||||||
|
assert_eq!(incoming.from, "Kari <kari@x.no>");
|
||||||
|
assert_eq!(incoming.text, "It is fixed.");
|
||||||
|
assert_eq!(incoming.id, "jmap:M1", "the mail's own id, so a repeat lands once");
|
||||||
|
assert_eq!(incoming.received_ms, 1790244000000, "2026-09-24T10:00:00Z");
|
||||||
|
assert_eq!(incoming.v, CONTRACT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mail_to_anywhere_else_is_left_for_a_person() {
|
||||||
|
assert!(mail_json("post@post.example.no", "hello").as_incoming(&inbox()).is_none());
|
||||||
|
// Addressed right, but there is nothing in it once the quote goes.
|
||||||
|
assert!(mail_json("case+hazards.a1b2c3@post.example.no", "> only a quote")
|
||||||
|
.as_incoming(&inbox())
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_inbox_needs_everything_or_nothing() {
|
||||||
|
// Half a configuration is no configuration: a reply address
|
||||||
|
// nobody reads is worse than none, because the reply goes
|
||||||
|
// nowhere at all.
|
||||||
|
for missing in ["JMAP_URL", "JMAP_TOKEN", "MAIL_REPLY_DOMAIN"] {
|
||||||
|
for (k, v) in [
|
||||||
|
("JMAP_URL", "https://mail.example.no/.well-known/jmap"),
|
||||||
|
("JMAP_TOKEN", "t"),
|
||||||
|
("MAIL_REPLY_DOMAIN", "post.example.no"),
|
||||||
|
] {
|
||||||
|
std::env::set_var(k, v);
|
||||||
|
}
|
||||||
|
std::env::remove_var(missing);
|
||||||
|
assert!(Inbox::from_env().is_none(), "{missing} missing");
|
||||||
|
}
|
||||||
|
std::env::set_var("JMAP_URL", "https://mail.example.no/.well-known/jmap");
|
||||||
|
std::env::set_var("JMAP_TOKEN", "t");
|
||||||
|
std::env::set_var("MAIL_REPLY_DOMAIN", "post.example.no");
|
||||||
|
let configured = Inbox::from_env().expect("all three");
|
||||||
|
assert_eq!(configured.prefix, "case");
|
||||||
|
assert_eq!(configured.every, 60);
|
||||||
|
for k in ["JMAP_URL", "JMAP_TOKEN", "MAIL_REPLY_DOMAIN"] {
|
||||||
|
std::env::remove_var(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_html_only_mail_falls_back_to_its_preview() {
|
||||||
|
let mail: Mail = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "M2",
|
||||||
|
"from": [{ "email": "kari@x.no" }],
|
||||||
|
"to": [{ "email": "case+hazards.a1b2c3@post.example.no" }],
|
||||||
|
"preview": "Looks good to me",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let incoming = mail.as_incoming(&inbox()).expect("a preview is better than nothing");
|
||||||
|
assert_eq!(incoming.text, "Looks good to me");
|
||||||
|
assert_eq!(incoming.from, "kari@x.no");
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-6
@@ -14,6 +14,8 @@
|
|||||||
//! SMTP_PORT (25), GDO_CONSUMER (gdo), GDO_TYPST_THEME (a .typ file;
|
//! SMTP_PORT (25), GDO_CONSUMER (gdo), GDO_TYPST_THEME (a .typ file;
|
||||||
//! set, every mail also carries a PDF rendered from it).
|
//! set, every mail also carries a PDF rendered from it).
|
||||||
|
|
||||||
|
mod inbox;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use lettre::{message::{header::ContentType, Attachment, MultiPart, SinglePart}, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
use lettre::{message::{header::ContentType, Attachment, MultiPart, SinglePart}, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||||
@@ -51,6 +53,8 @@ struct Config {
|
|||||||
smtp_helo: String,
|
smtp_helo: String,
|
||||||
consumer: String,
|
consumer: String,
|
||||||
typst_theme: Option<String>,
|
typst_theme: Option<String>,
|
||||||
|
/// The mailbox to read replies from, when there is one.
|
||||||
|
inbox: Option<inbox::Inbox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -63,6 +67,7 @@ impl Config {
|
|||||||
smtp_helo: var("SMTP_HELO", &fqdn()),
|
smtp_helo: var("SMTP_HELO", &fqdn()),
|
||||||
consumer: var("GDO_CONSUMER", "gdo"),
|
consumer: var("GDO_CONSUMER", "gdo"),
|
||||||
typst_theme: std::env::var("GDO_TYPST_THEME").ok().filter(|v| !v.is_empty()),
|
typst_theme: std::env::var("GDO_TYPST_THEME").ok().filter(|v| !v.is_empty()),
|
||||||
|
inbox: inbox::Inbox::from_env(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,14 +106,24 @@ async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result<as
|
|||||||
|
|
||||||
/// The mail as SMTP sees it: text and HTML alternatives, and the
|
/// The mail as SMTP sees it: text and HTML alternatives, and the
|
||||||
/// themed PDF when a theme is set.
|
/// themed PDF when a theme is set.
|
||||||
fn build_message(mail: &Outgoing, pdf: Option<Vec<u8>>) -> anyhow::Result<Message> {
|
fn build_message(mail: &Outgoing, pdf: Option<Vec<u8>>, inbox: Option<&inbox::Inbox>) -> anyhow::Result<Message> {
|
||||||
let mut builder = Message::builder()
|
let mut builder = Message::builder()
|
||||||
.from(mail.from.parse().with_context(|| format!("from address {:?}", mail.from))?)
|
.from(mail.from.parse().with_context(|| format!("from address {:?}", mail.from))?)
|
||||||
.to(mail.to.parse().with_context(|| format!("to address {:?}", mail.to))?)
|
.to(mail.to.parse().with_context(|| format!("to address {:?}", mail.to))?)
|
||||||
.subject(&mail.subject);
|
.subject(&mail.subject);
|
||||||
if let Some(reply_to) = &mail.reply_to {
|
// A mail about a record gets a Reply-To of its own, so an answer
|
||||||
|
// comes back to the case rather than to a mailbox nobody reads -
|
||||||
|
// but only where something is reading that mailbox. Otherwise the
|
||||||
|
// site's own reply address stands, as it always has.
|
||||||
|
match (inbox.and_then(|i| i.reply_address(&mail.id)), &mail.reply_to) {
|
||||||
|
(Some(address), _) => {
|
||||||
|
builder = builder.reply_to(address.parse().with_context(|| format!("case address {address:?}"))?);
|
||||||
|
}
|
||||||
|
(None, Some(reply_to)) => {
|
||||||
builder = builder.reply_to(reply_to.parse().with_context(|| format!("reply-to address {reply_to:?}"))?);
|
builder = builder.reply_to(reply_to.parse().with_context(|| format!("reply-to address {reply_to:?}"))?);
|
||||||
}
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
let alternative = MultiPart::alternative()
|
let alternative = MultiPart::alternative()
|
||||||
.singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(mail.text.clone()))
|
.singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(mail.text.clone()))
|
||||||
.singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(mail.html.clone()));
|
.singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(mail.html.clone()));
|
||||||
@@ -157,6 +172,7 @@ async fn render_theme(theme: &str, mail: &Outgoing) -> Option<Vec<u8>> {
|
|||||||
|
|
||||||
async fn serve(cfg: Config) -> anyhow::Result<()> {
|
async fn serve(cfg: Config) -> anyhow::Result<()> {
|
||||||
let nats = connect(&cfg.nats_url).await?;
|
let nats = connect(&cfg.nats_url).await?;
|
||||||
|
let nats_for_inbox = nats.clone();
|
||||||
let js = async_nats::jetstream::new(nats);
|
let js = async_nats::jetstream::new(nats);
|
||||||
let stream = ensure_stream(&js).await?;
|
let stream = ensure_stream(&js).await?;
|
||||||
let transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.smtp_host)
|
let transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.smtp_host)
|
||||||
@@ -180,6 +196,26 @@ async fn serve(cfg: Config) -> anyhow::Result<()> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
// The way back in, when a mailbox is configured: its own loop, so
|
||||||
|
// a mail server that hangs cannot hold up the sending.
|
||||||
|
if let Some(mailbox) = cfg.inbox.clone() {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("http client");
|
||||||
|
tracing::info!(url = %mailbox.url, every = mailbox.every, "reading replies");
|
||||||
|
loop {
|
||||||
|
match inbox::sweep(&mailbox, &nats_for_inbox, &http).await {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!("{n} reply(ies) handed to portal"),
|
||||||
|
Err(e) => tracing::warn!("reading the mailbox failed: {e}"),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(mailbox.every)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut messages = consumer.messages().await?;
|
let mut messages = consumer.messages().await?;
|
||||||
tracing::info!(consumer = %cfg.consumer, "listening on {STREAM}");
|
tracing::info!(consumer = %cfg.consumer, "listening on {STREAM}");
|
||||||
while let Some(next) = messages.next().await {
|
while let Some(next) = messages.next().await {
|
||||||
@@ -209,7 +245,7 @@ async fn serve(cfg: Config) -> anyhow::Result<()> {
|
|||||||
Some(theme) => render_theme(theme, &mail).await,
|
Some(theme) => render_theme(theme, &mail).await,
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let message = match build_message(&mail, pdf) {
|
let message = match build_message(&mail, pdf, cfg.inbox.as_ref()) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(id = %mail.id, to = %mail.to, "cannot build the message: {e}; dropped");
|
tracing::error!(id = %mail.id, to = %mail.to, "cannot build the message: {e}; dropped");
|
||||||
@@ -340,7 +376,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_message_carries_both_alternatives_and_its_headers() {
|
fn a_message_carries_both_alternatives_and_its_headers() {
|
||||||
let m = build_message(&mail(), None).unwrap();
|
let m = build_message(&mail(), None, None).unwrap();
|
||||||
let raw = String::from_utf8(m.formatted()).unwrap();
|
let raw = String::from_utf8(m.formatted()).unwrap();
|
||||||
assert!(raw.contains("From: \"Tomter Vel\" <vel@example.no>"));
|
assert!(raw.contains("From: \"Tomter Vel\" <vel@example.no>"));
|
||||||
assert!(raw.contains("Reply-To: post@example.no"));
|
assert!(raw.contains("Reply-To: post@example.no"));
|
||||||
@@ -350,9 +386,36 @@ mod tests {
|
|||||||
assert!(!raw.contains("application/pdf"));
|
assert!(!raw.contains("application/pdf"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_case_mail_answers_to_its_own_case_when_someone_reads_the_mailbox() {
|
||||||
|
let mailbox = inbox::Inbox {
|
||||||
|
url: "https://mail.example.no/.well-known/jmap".into(),
|
||||||
|
token: "t".into(),
|
||||||
|
domain: "post.example.no".into(),
|
||||||
|
prefix: "case".into(),
|
||||||
|
every: 60,
|
||||||
|
};
|
||||||
|
// mail() carries id "reports:abc:fixed" and the site's own
|
||||||
|
// reply address; the case address wins over it.
|
||||||
|
let raw = String::from_utf8(build_message(&mail(), None, Some(&mailbox)).unwrap().formatted()).unwrap();
|
||||||
|
assert!(raw.contains("Reply-To: case+reports.abc@post.example.no"), "{raw}");
|
||||||
|
assert!(!raw.contains("Reply-To: post@example.no"));
|
||||||
|
|
||||||
|
// An invite is not a case: it keeps the site's address, so a
|
||||||
|
// person reads the answer.
|
||||||
|
let mut invite = mail();
|
||||||
|
invite.id = "invite:kari:board:17".into();
|
||||||
|
let raw = String::from_utf8(build_message(&invite, None, Some(&mailbox)).unwrap().formatted()).unwrap();
|
||||||
|
assert!(raw.contains("Reply-To: post@example.no"), "{raw}");
|
||||||
|
|
||||||
|
// And with no mailbox at all, nothing changes for anyone.
|
||||||
|
let raw = String::from_utf8(build_message(&mail(), None, None).unwrap().formatted()).unwrap();
|
||||||
|
assert!(raw.contains("Reply-To: post@example.no"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_theme_makes_it_mixed_with_a_pdf() {
|
fn a_theme_makes_it_mixed_with_a_pdf() {
|
||||||
let m = build_message(&mail(), Some(b"%PDF-1.7".to_vec())).unwrap();
|
let m = build_message(&mail(), Some(b"%PDF-1.7".to_vec()), None).unwrap();
|
||||||
let raw = String::from_utf8(m.formatted()).unwrap();
|
let raw = String::from_utf8(m.formatted()).unwrap();
|
||||||
assert!(raw.contains("multipart/mixed") && raw.contains("application/pdf") && raw.contains("Tomter Vel.pdf"));
|
assert!(raw.contains("multipart/mixed") && raw.contains("application/pdf") && raw.contains("Tomter Vel.pdf"));
|
||||||
}
|
}
|
||||||
@@ -361,7 +424,7 @@ mod tests {
|
|||||||
fn a_bad_address_is_refused_before_smtp() {
|
fn a_bad_address_is_refused_before_smtp() {
|
||||||
let mut bad = mail();
|
let mut bad = mail();
|
||||||
bad.to = "not an address".into();
|
bad.to = "not an address".into();
|
||||||
assert!(build_message(&bad, None).is_err());
|
assert!(build_message(&bad, None, None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user