Compare commits
18 Commits
5c0c45b99f
...
1106377550
| Author | SHA1 | Date | |
|---|---|---|---|
| 1106377550 | |||
| b5468bced2 | |||
| 01cbe6c037 | |||
| d0a02c2f61 | |||
| c499672dde | |||
| 3aa0b94db4 | |||
| cdb64ed952 | |||
| 834efc5c94 | |||
| 79db94f67f | |||
| ec41f840d5 | |||
| d9d57c66f8 | |||
| 9746c9912b | |||
| abaaddae3a | |||
| 0bf64004ff | |||
| 6fae9cd018 | |||
| 65fcbd4b77 | |||
| dd09bc3168 | |||
| 0bf865fdef |
10
.envrc
Normal file
10
.envrc
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
source_up
|
||||||
|
|
||||||
|
export DATABASE_USER="newsreader";
|
||||||
|
export DATABASE_NAME="newsreader";
|
||||||
|
export DATABASE_HOST="nixos-07.h.xinu.tv";
|
||||||
|
export DATABASE_URL="postgres://${DATABASE_USER}@${DATABASE_HOST}/${DATABASE_NAME}";
|
||||||
|
export PROD_DATABASE_USER="newsreader";
|
||||||
|
export PROD_DATABASE_NAME="newsreader";
|
||||||
|
export PROD_DATABASE_HOST="postgres.h.xinu.tv";
|
||||||
|
export PROD_DATABASE_URL="postgres://${PROD_DATABASE_USER}@${PROD_DATABASE_HOST}/${PROD_DATABASE_NAME}";
|
||||||
597
Cargo.lock
generated
597
Cargo.lock
generated
@ -45,6 +45,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
|
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"getrandom 0.2.15",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"version_check",
|
"version_check",
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
@ -59,6 +60,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ammonia"
|
name = "ammonia"
|
||||||
version = "3.3.0"
|
version = "3.3.0"
|
||||||
@ -241,6 +248,15 @@ dependencies = [
|
|||||||
"syn 2.0.69",
|
"syn 2.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atoi"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic"
|
name = "atomic"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@ -312,6 +328,12 @@ version = "0.21.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bimap"
|
name = "bimap"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@ -335,6 +357,9 @@ name = "bitflags"
|
|||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
@ -441,6 +466,12 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-sha1"
|
name = "const-sha1"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -489,6 +520,21 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc"
|
||||||
|
version = "3.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636"
|
||||||
|
dependencies = [
|
||||||
|
"crc-catalog",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc-catalog"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.5"
|
version = "0.8.5"
|
||||||
@ -508,6 +554,15 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-queue"
|
||||||
|
version = "0.3.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.20"
|
version = "0.8.20"
|
||||||
@ -658,6 +713,17 @@ version = "2.6.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
|
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"pem-rfc7468",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@ -726,9 +792,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dotenvy"
|
||||||
|
version = "0.15.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "downcast-rs"
|
name = "downcast-rs"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@ -769,6 +843,9 @@ name = "either"
|
|||||||
version = "1.13.0"
|
version = "1.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "enclose"
|
name = "enclose"
|
||||||
@ -801,6 +878,23 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "etcetera"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"home",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener"
|
||||||
|
version = "2.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fast_chemail"
|
name = "fast_chemail"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@ -830,6 +924,17 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flume"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"spin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@ -897,6 +1002,17 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-intrusive"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"lock_api",
|
||||||
|
"parking_lot 0.12.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.30"
|
version = "0.3.30"
|
||||||
@ -1249,12 +1365,28 @@ name = "hashbrown"
|
|||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash 0.8.11",
|
||||||
|
"allocator-api2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
@ -1262,6 +1394,39 @@ version = "0.3.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hkdf"
|
||||||
|
version = "0.12.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||||
|
dependencies = [
|
||||||
|
"hmac",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "home"
|
||||||
|
version = "0.5.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.26.0"
|
version = "0.26.0"
|
||||||
@ -1523,6 +1688,9 @@ name = "lazy_static"
|
|||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
dependencies = [
|
||||||
|
"spin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazycell"
|
name = "lazycell"
|
||||||
@ -1559,6 +1727,23 @@ version = "0.2.155"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
|
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libm"
|
||||||
|
version = "0.2.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linkify"
|
name = "linkify"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@ -1676,6 +1861,16 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.4"
|
version = "2.7.4"
|
||||||
@ -1707,6 +1902,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
@ -1789,6 +1990,16 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notmuch"
|
name = "notmuch"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -1802,12 +2013,49 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint-dig"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"lazy_static",
|
||||||
|
"libm",
|
||||||
|
"num-integer",
|
||||||
|
"num-iter",
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"smallvec",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-iter"
|
||||||
|
version = "0.1.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@ -1815,6 +2063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1928,6 +2177,15 @@ dependencies = [
|
|||||||
"syn 2.0.69",
|
"syn 2.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem-rfc7468"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
@ -2151,6 +2409,33 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs1"
|
||||||
|
version = "0.7.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"pkcs8",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -2610,6 +2895,26 @@ dependencies = [
|
|||||||
"uncased",
|
"uncased",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsa"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"digest",
|
||||||
|
"num-bigint-dig",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"pkcs1",
|
||||||
|
"pkcs8",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"signature",
|
||||||
|
"spki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.24"
|
version = "0.1.24"
|
||||||
@ -2875,8 +3180,10 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shared",
|
"shared",
|
||||||
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -2899,6 +3206,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.8"
|
version = "0.10.8"
|
||||||
@ -2936,6 +3254,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@ -2981,6 +3309,227 @@ name = "spin"
|
|||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlformat"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f895e3734318cc55f1fe66258926c9b910c124d47520339efecbb6c59cec7c1f"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
"unicode_categories",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlx"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa"
|
||||||
|
dependencies = [
|
||||||
|
"sqlx-core",
|
||||||
|
"sqlx-macros",
|
||||||
|
"sqlx-mysql",
|
||||||
|
"sqlx-postgres",
|
||||||
|
"sqlx-sqlite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlx-core"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6"
|
||||||
|
dependencies = [
|
||||||
|
"ahash 0.8.11",
|
||||||
|
"atoi",
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"crc",
|
||||||
|
"crossbeam-queue",
|
||||||
|
"either",
|
||||||
|
"event-listener",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-intrusive",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"hashlink",
|
||||||
|
"hex",
|
||||||
|
"indexmap 2.2.6",
|
||||||
|
"log",
|
||||||
|
"memchr",
|
||||||
|
"once_cell",
|
||||||
|
"paste",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"smallvec",
|
||||||
|
"sqlformat",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlx-macros"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"sqlx-core",
|
||||||
|
"sqlx-macros-core",
|
||||||
|
"syn 1.0.109",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlx-macros-core"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8"
|
||||||
|
dependencies = [
|
||||||
|
"dotenvy",
|
||||||
|
"either",
|
||||||
|
"heck",
|
||||||
|
"hex",
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"sqlx-core",
|
||||||
|
"sqlx-mysql",
|
||||||
|
"sqlx-postgres",
|
||||||
|
"sqlx-sqlite",
|
||||||
|
"syn 1.0.109",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlx-mysql"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
|
||||||
|
dependencies = [
|
||||||
|
"atoi",
|
||||||
|
"base64 0.21.7",
|
||||||
|
"bitflags 2.6.0",
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"crc",
|
||||||
|
"digest",
|
||||||
|
"dotenvy",
|
||||||
|
"either",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"generic-array",
|
||||||
|
"hex",
|
||||||
|
"hkdf",
|
||||||
|
"hmac",
|
||||||
|
"itoa 1.0.11",
|
||||||
|
"log",
|
||||||
|
"md-5",
|
||||||
|
"memchr",
|
||||||
|
"once_cell",
|
||||||
|
"percent-encoding",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"rsa",
|
||||||
|
"serde",
|
||||||
|
"sha1",
|
||||||
|
"sha2",
|
||||||
|
"smallvec",
|
||||||
|
"sqlx-core",
|
||||||
|
"stringprep",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tracing",
|
||||||
|
"whoami",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlx-postgres"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
|
||||||
|
dependencies = [
|
||||||
|
"atoi",
|
||||||
|
"base64 0.21.7",
|
||||||
|
"bitflags 2.6.0",
|
||||||
|
"byteorder",
|
||||||
|
"crc",
|
||||||
|
"dotenvy",
|
||||||
|
"etcetera",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"hkdf",
|
||||||
|
"hmac",
|
||||||
|
"home",
|
||||||
|
"itoa 1.0.11",
|
||||||
|
"log",
|
||||||
|
"md-5",
|
||||||
|
"memchr",
|
||||||
|
"once_cell",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"smallvec",
|
||||||
|
"sqlx-core",
|
||||||
|
"stringprep",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tracing",
|
||||||
|
"whoami",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlx-sqlite"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
|
||||||
|
dependencies = [
|
||||||
|
"atoi",
|
||||||
|
"flume",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-intrusive",
|
||||||
|
"futures-util",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
"sqlx-core",
|
||||||
|
"time",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"urlencoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable-pattern"
|
name = "stable-pattern"
|
||||||
@ -3038,6 +3587,17 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stringprep"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-bidi",
|
||||||
|
"unicode-normalization",
|
||||||
|
"unicode-properties",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.9.3"
|
version = "0.9.3"
|
||||||
@ -3072,6 +3632,12 @@ dependencies = [
|
|||||||
"syn 2.0.69",
|
"syn 2.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@ -3378,6 +3944,7 @@ version = "0.1.40"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
|
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
@ -3510,12 +4077,30 @@ dependencies = [
|
|||||||
"tinyvec",
|
"tinyvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-properties"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
|
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode_categories"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unreachable"
|
name = "unreachable"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@ -3569,6 +4154,12 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
|
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@ -4003,3 +4594,9 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.69",
|
"syn 2.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||||
|
|||||||
@ -28,4 +28,6 @@ css-inline = "0.13.0"
|
|||||||
anyhow = "1.0.79"
|
anyhow = "1.0.79"
|
||||||
maplit = "1.0.2"
|
maplit = "1.0.2"
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
|
sqlx = { version = "0.7.4", features = ["postgres", "runtime-tokio", "time"] }
|
||||||
|
url = "2.5.2"
|
||||||
|
|
||||||
|
|||||||
@ -7,3 +7,4 @@ address = "0.0.0.0"
|
|||||||
port = 9345
|
port = 9345
|
||||||
# Uncomment to make it production like.
|
# Uncomment to make it production like.
|
||||||
#log_level = "critical"
|
#log_level = "critical"
|
||||||
|
newsreader_database_url = "postgres://newsreader@nixos-07.h.xinu.tv/newsreader"
|
||||||
|
|||||||
10
server/sql/count.sql
Normal file
10
server/sql/count.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
SELECT
|
||||||
|
COUNT(*) count
|
||||||
|
FROM
|
||||||
|
post
|
||||||
|
WHERE
|
||||||
|
site = $1
|
||||||
|
AND (
|
||||||
|
NOT $2
|
||||||
|
OR NOT is_read
|
||||||
|
)
|
||||||
21
server/sql/tags.sql
Normal file
21
server/sql/tags.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
SELECT
|
||||||
|
site,
|
||||||
|
name,
|
||||||
|
count (
|
||||||
|
NOT is_read
|
||||||
|
OR NULL
|
||||||
|
) unread
|
||||||
|
FROM
|
||||||
|
post AS p
|
||||||
|
JOIN feed AS f ON p.site = f.slug --
|
||||||
|
-- TODO: figure this out to make the query faster when only looking for unread
|
||||||
|
--WHERE
|
||||||
|
-- (
|
||||||
|
-- NOT $1
|
||||||
|
-- OR NOT is_read
|
||||||
|
-- )
|
||||||
|
GROUP BY
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
ORDER BY
|
||||||
|
site
|
||||||
14
server/sql/thread.sql
Normal file
14
server/sql/thread.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
SELECT
|
||||||
|
date,
|
||||||
|
is_read,
|
||||||
|
link,
|
||||||
|
site,
|
||||||
|
summary,
|
||||||
|
title,
|
||||||
|
name,
|
||||||
|
homepage
|
||||||
|
FROM
|
||||||
|
post p
|
||||||
|
JOIN feed f ON p.site = f.slug
|
||||||
|
WHERE
|
||||||
|
uid = $1
|
||||||
20
server/sql/threads.sql
Normal file
20
server/sql/threads.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
SELECT
|
||||||
|
date,
|
||||||
|
is_read,
|
||||||
|
title,
|
||||||
|
uid,
|
||||||
|
name
|
||||||
|
FROM
|
||||||
|
post p
|
||||||
|
JOIN feed f ON p.site = f.slug
|
||||||
|
WHERE
|
||||||
|
site = $1
|
||||||
|
AND (
|
||||||
|
NOT $2
|
||||||
|
OR NOT is_read
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
date DESC,
|
||||||
|
title OFFSET $3
|
||||||
|
LIMIT
|
||||||
|
$4
|
||||||
@ -7,6 +7,7 @@ use async_graphql_rocket::{GraphQLQuery, GraphQLRequest, GraphQLResponse};
|
|||||||
use glog::Flags;
|
use glog::Flags;
|
||||||
use notmuch::{Notmuch, NotmuchError, ThreadSet};
|
use notmuch::{Notmuch, NotmuchError, ThreadSet};
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
fairing::AdHoc,
|
||||||
http::{ContentType, Header},
|
http::{ContentType, Header},
|
||||||
request::Request,
|
request::Request,
|
||||||
response::{content, Debug, Responder},
|
response::{content, Debug, Responder},
|
||||||
@ -14,46 +15,24 @@ use rocket::{
|
|||||||
Response, State,
|
Response, State,
|
||||||
};
|
};
|
||||||
use rocket_cors::{AllowedHeaders, AllowedOrigins};
|
use rocket_cors::{AllowedHeaders, AllowedOrigins};
|
||||||
|
use serde::Deserialize;
|
||||||
use server::{
|
use server::{
|
||||||
error::ServerError,
|
error::ServerError,
|
||||||
graphql::{
|
graphql::{Attachment, GraphqlSchema, Mutation, QueryRoot},
|
||||||
attachment_bytes, cid_attachment_bytes, Attachment, GraphqlSchema, Mutation, QueryRoot,
|
nm::{attachment_bytes, cid_attachment_bytes},
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Config {
|
||||||
|
newsreader_database_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/refresh")]
|
#[get("/refresh")]
|
||||||
async fn refresh(nm: &State<Notmuch>) -> Result<Json<String>, Debug<NotmuchError>> {
|
async fn refresh(nm: &State<Notmuch>) -> Result<Json<String>, Debug<NotmuchError>> {
|
||||||
Ok(Json(String::from_utf8_lossy(&nm.new()?).to_string()))
|
Ok(Json(String::from_utf8_lossy(&nm.new()?).to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/search")]
|
|
||||||
async fn search_all(
|
|
||||||
nm: &State<Notmuch>,
|
|
||||||
) -> Result<Json<shared::SearchResult>, Debug<NotmuchError>> {
|
|
||||||
search(nm, "*", None, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/search/<query>?<page>&<results_per_page>")]
|
|
||||||
async fn search(
|
|
||||||
nm: &State<Notmuch>,
|
|
||||||
query: &str,
|
|
||||||
page: Option<usize>,
|
|
||||||
results_per_page: Option<usize>,
|
|
||||||
) -> Result<Json<shared::SearchResult>, Debug<NotmuchError>> {
|
|
||||||
let page = page.unwrap_or(0);
|
|
||||||
let results_per_page = results_per_page.unwrap_or(20);
|
|
||||||
let query = urlencoding::decode(query).map_err(NotmuchError::from)?;
|
|
||||||
info!(" search '{query}'");
|
|
||||||
let res = shared::SearchResult {
|
|
||||||
summary: nm.search(&query, page * results_per_page, results_per_page)?,
|
|
||||||
query: query.to_string(),
|
|
||||||
page,
|
|
||||||
results_per_page,
|
|
||||||
total: nm.count(&query)?,
|
|
||||||
};
|
|
||||||
Ok(Json(res))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/show/<query>/pretty")]
|
#[get("/show/<query>/pretty")]
|
||||||
async fn show_pretty(
|
async fn show_pretty(
|
||||||
nm: &State<Notmuch>,
|
nm: &State<Notmuch>,
|
||||||
@ -224,19 +203,12 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
.to_cors()?;
|
.to_cors()?;
|
||||||
|
|
||||||
let schema = Schema::build(QueryRoot, Mutation, EmptySubscription)
|
let rkt = rocket::build()
|
||||||
.data(Notmuch::default())
|
|
||||||
.extension(async_graphql::extensions::Logger)
|
|
||||||
.finish();
|
|
||||||
|
|
||||||
let _ = rocket::build()
|
|
||||||
.mount(
|
.mount(
|
||||||
shared::urls::MOUNT_POINT,
|
shared::urls::MOUNT_POINT,
|
||||||
routes![
|
routes![
|
||||||
original,
|
original,
|
||||||
refresh,
|
refresh,
|
||||||
search_all,
|
|
||||||
search,
|
|
||||||
show_pretty,
|
show_pretty,
|
||||||
show,
|
show,
|
||||||
graphql_query,
|
graphql_query,
|
||||||
@ -248,11 +220,19 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.attach(cors)
|
.attach(cors)
|
||||||
.manage(schema)
|
.attach(AdHoc::config::<Config>());
|
||||||
.manage(Notmuch::default())
|
|
||||||
//.manage(Notmuch::with_config("../notmuch/testdata/notmuch.config"))
|
|
||||||
.launch()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
|
let config: Config = rkt.figment().extract()?;
|
||||||
|
let pool = PgPool::connect(&config.newsreader_database_url).await?;
|
||||||
|
let schema = Schema::build(QueryRoot, Mutation, EmptySubscription)
|
||||||
|
.data(Notmuch::default())
|
||||||
|
.data(pool.clone())
|
||||||
|
.extension(async_graphql::extensions::Logger)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
let rkt = rkt.manage(schema).manage(pool).manage(Notmuch::default());
|
||||||
|
//.manage(Notmuch::with_config("../notmuch/testdata/notmuch.config"))
|
||||||
|
|
||||||
|
rkt.launch().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,34 @@
|
|||||||
|
use std::{convert::Infallible, str::Utf8Error, string::FromUtf8Error};
|
||||||
|
|
||||||
use mailparse::MailParseError;
|
use mailparse::MailParseError;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::SanitizeError;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ServerError {
|
pub enum ServerError {
|
||||||
#[error("notmuch")]
|
#[error("notmuch: {0}")]
|
||||||
NotmuchError(#[from] notmuch::NotmuchError),
|
NotmuchError(#[from] notmuch::NotmuchError),
|
||||||
#[error("flatten")]
|
#[error("flatten")]
|
||||||
FlattenError,
|
FlattenError,
|
||||||
#[error("mail parse error")]
|
#[error("mail parse error: {0}")]
|
||||||
MailParseError(#[from] MailParseError),
|
MailParseError(#[from] MailParseError),
|
||||||
#[error("IO error")]
|
#[error("IO error: {0}")]
|
||||||
IoError(#[from] std::io::Error),
|
IoError(#[from] std::io::Error),
|
||||||
#[error("attachement not found")]
|
#[error("attachement not found")]
|
||||||
PartNotFound,
|
PartNotFound,
|
||||||
|
#[error("sqlx error: {0}")]
|
||||||
|
SQLXError(#[from] sqlx::Error),
|
||||||
|
#[error("html sanitize error: {0}")]
|
||||||
|
SanitizeError(#[from] SanitizeError),
|
||||||
|
#[error("UTF8 error: {0}")]
|
||||||
|
Utf8Error(#[from] Utf8Error),
|
||||||
|
#[error("FromUTF8 error: {0}")]
|
||||||
|
FromUtf8Error(#[from] FromUtf8Error),
|
||||||
|
#[error("error: {0}")]
|
||||||
|
StringError(String),
|
||||||
|
#[error("invalid url: {0}")]
|
||||||
|
UrlParseError(#[from] url::ParseError),
|
||||||
|
#[error("impossible: {0}")]
|
||||||
|
InfaillibleError(#[from] Infallible),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,13 @@
|
|||||||
const MAX_RAW_MESSAGE_SIZE: usize = 100_000;
|
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
fs::File,
|
|
||||||
hash::{DefaultHasher, Hash, Hasher},
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
|
|
||||||
use async_graphql::{
|
use async_graphql::{
|
||||||
connection::{self, Connection, Edge},
|
connection::{Connection},
|
||||||
Context, EmptySubscription, Enum, Error, FieldResult, Object, Schema, SimpleObject, Union,
|
Context, EmptySubscription, Enum, Error, FieldResult, Object, Schema, SimpleObject, Union,
|
||||||
};
|
};
|
||||||
use log::{error, info, warn};
|
use log::info;
|
||||||
use mailparse::{parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
|
||||||
use memmap::MmapOptions;
|
|
||||||
use notmuch::Notmuch;
|
use notmuch::Notmuch;
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
|
||||||
use crate::{error::ServerError, linkify_html, sanitize_html};
|
use crate::{newsreader, nm};
|
||||||
|
|
||||||
/// # Number of seconds since the Epoch
|
/// # Number of seconds since the Epoch
|
||||||
pub type UnixTime = isize;
|
pub type UnixTime = isize;
|
||||||
@ -23,14 +15,6 @@ pub type UnixTime = isize;
|
|||||||
/// # Thread ID, sans "thread:"
|
/// # Thread ID, sans "thread:"
|
||||||
pub type ThreadId = String;
|
pub type ThreadId = String;
|
||||||
|
|
||||||
const TEXT_PLAIN: &'static str = "text/plain";
|
|
||||||
const TEXT_HTML: &'static str = "text/html";
|
|
||||||
const IMAGE_JPEG: &'static str = "image/jpeg";
|
|
||||||
const IMAGE_PNG: &'static str = "image/png";
|
|
||||||
const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative";
|
|
||||||
const MULTIPART_MIXED: &'static str = "multipart/mixed";
|
|
||||||
const MULTIPART_RELATED: &'static str = "multipart/related";
|
|
||||||
|
|
||||||
#[derive(Debug, SimpleObject)]
|
#[derive(Debug, SimpleObject)]
|
||||||
pub struct ThreadSummary {
|
pub struct ThreadSummary {
|
||||||
pub thread: ThreadId,
|
pub thread: ThreadId,
|
||||||
@ -49,9 +33,9 @@ pub struct ThreadSummary {
|
|||||||
|
|
||||||
#[derive(Debug, SimpleObject)]
|
#[derive(Debug, SimpleObject)]
|
||||||
pub struct Thread {
|
pub struct Thread {
|
||||||
thread_id: String,
|
pub thread_id: String,
|
||||||
subject: String,
|
pub subject: String,
|
||||||
messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, SimpleObject)]
|
#[derive(Debug, SimpleObject)]
|
||||||
@ -95,16 +79,45 @@ pub struct Attachment {
|
|||||||
pub bytes: Vec<u8>,
|
pub bytes: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||||
|
pub struct Disposition {
|
||||||
|
pub r#type: DispositionType,
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub size: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)]
|
||||||
|
pub enum DispositionType {
|
||||||
|
Inline,
|
||||||
|
Attachment,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<mailparse::DispositionType> for DispositionType {
|
||||||
|
fn from(value: mailparse::DispositionType) -> Self {
|
||||||
|
match value {
|
||||||
|
mailparse::DispositionType::Inline => DispositionType::Inline,
|
||||||
|
mailparse::DispositionType::Attachment => DispositionType::Attachment,
|
||||||
|
dt => panic!("unhandled DispositionType {dt:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DispositionType {
|
||||||
|
fn default() -> Self {
|
||||||
|
DispositionType::Attachment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, SimpleObject)]
|
#[derive(Debug, SimpleObject)]
|
||||||
pub struct Header {
|
pub struct Header {
|
||||||
key: String,
|
pub key: String,
|
||||||
value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct UnhandledContentType {
|
pub struct UnhandledContentType {
|
||||||
text: String,
|
pub text: String,
|
||||||
content_tree: String,
|
pub content_tree: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Object]
|
#[Object]
|
||||||
@ -119,8 +132,8 @@ impl UnhandledContentType {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PlainText {
|
pub struct PlainText {
|
||||||
text: String,
|
pub text: String,
|
||||||
content_tree: String,
|
pub content_tree: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Object]
|
#[Object]
|
||||||
@ -135,8 +148,8 @@ impl PlainText {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Html {
|
pub struct Html {
|
||||||
html: String,
|
pub html: String,
|
||||||
content_tree: String,
|
pub content_tree: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Object]
|
#[Object]
|
||||||
@ -160,13 +173,13 @@ pub enum Body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Body {
|
impl Body {
|
||||||
fn html(html: String) -> Body {
|
pub fn html(html: String) -> Body {
|
||||||
Body::Html(Html {
|
Body::Html(Html {
|
||||||
html,
|
html,
|
||||||
content_tree: "".to_string(),
|
content_tree: "".to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn text(text: String) -> Body {
|
pub fn text(text: String) -> Body {
|
||||||
Body::PlainText(PlainText {
|
Body::PlainText(PlainText {
|
||||||
text,
|
text,
|
||||||
content_tree: "".to_string(),
|
content_tree: "".to_string(),
|
||||||
@ -181,11 +194,11 @@ pub struct Email {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(SimpleObject)]
|
#[derive(SimpleObject)]
|
||||||
struct Tag {
|
pub(crate) struct Tag {
|
||||||
name: String,
|
pub name: String,
|
||||||
fg_color: String,
|
pub fg_color: String,
|
||||||
bg_color: String,
|
pub bg_color: String,
|
||||||
unread: usize,
|
pub unread: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct QueryRoot;
|
pub struct QueryRoot;
|
||||||
@ -193,7 +206,14 @@ pub struct QueryRoot;
|
|||||||
impl QueryRoot {
|
impl QueryRoot {
|
||||||
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
|
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
Ok(nm.count(&query)?)
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
|
|
||||||
|
// TODO: make this search both copra and merge results
|
||||||
|
if newsreader::is_newsreader_search(&query) {
|
||||||
|
Ok(newsreader::count(pool, &query).await?)
|
||||||
|
} else {
|
||||||
|
Ok(nm::count(nm, &query).await?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search<'ctx>(
|
async fn search<'ctx>(
|
||||||
@ -205,230 +225,41 @@ impl QueryRoot {
|
|||||||
last: Option<i32>,
|
last: Option<i32>,
|
||||||
query: String,
|
query: String,
|
||||||
) -> Result<Connection<usize, ThreadSummary>, Error> {
|
) -> Result<Connection<usize, ThreadSummary>, Error> {
|
||||||
|
info!("search({after:?} {before:?} {first:?} {last:?} {query:?})");
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
connection::query(
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
after,
|
|
||||||
before,
|
// TODO: make this search both copra and merge results
|
||||||
first,
|
if newsreader::is_newsreader_search(&query) {
|
||||||
last,
|
Ok(newsreader::search(pool, after, before, first, last, query).await?)
|
||||||
|after, before, first, last| async move {
|
|
||||||
let total = nm.count(&query)?;
|
|
||||||
let (first, last) = if let (None, None) = (first, last) {
|
|
||||||
info!("neither first nor last set, defaulting first to 20");
|
|
||||||
(Some(20), None)
|
|
||||||
} else {
|
} else {
|
||||||
(first, last)
|
Ok(nm::search(nm, after, before, first, last, query).await?)
|
||||||
};
|
|
||||||
|
|
||||||
let mut start = after.map(|after| after + 1).unwrap_or(0);
|
|
||||||
let mut end = before.unwrap_or(total);
|
|
||||||
if let Some(first) = first {
|
|
||||||
end = (start + first).min(end);
|
|
||||||
}
|
}
|
||||||
if let Some(last) = last {
|
|
||||||
start = if last > end - start { end } else { end - last };
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = end - start;
|
|
||||||
let slice: Vec<ThreadSummary> = nm
|
|
||||||
.search(&query, start, count)?
|
|
||||||
.0
|
|
||||||
.into_iter()
|
|
||||||
.map(|ts| ThreadSummary {
|
|
||||||
thread: ts.thread,
|
|
||||||
timestamp: ts.timestamp,
|
|
||||||
date_relative: ts.date_relative,
|
|
||||||
matched: ts.matched,
|
|
||||||
total: ts.total,
|
|
||||||
authors: ts.authors,
|
|
||||||
subject: ts.subject,
|
|
||||||
tags: ts.tags,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut connection = Connection::new(start > 0, end < total);
|
|
||||||
connection.edges.extend(
|
|
||||||
slice
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, item)| Edge::new(start + idx, item)),
|
|
||||||
);
|
|
||||||
Ok::<_, Error>(connection)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {
|
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
let now = Instant::now();
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
let needs_unread = ctx.look_ahead().field("unread").exists();
|
let needs_unread = ctx.look_ahead().field("unread").exists();
|
||||||
let unread_msg_cnt: HashMap<String, usize> = if needs_unread {
|
let mut tags = newsreader::tags(pool, needs_unread).await?;
|
||||||
// 10000 is an arbitrary number, if there's more than 10k unread messages, we'll
|
tags.append(&mut nm::tags(nm, needs_unread)?);
|
||||||
// get an inaccurate count.
|
|
||||||
nm.search("is:unread", 0, 10000)?
|
|
||||||
.0
|
|
||||||
.iter()
|
|
||||||
.fold(HashMap::new(), |mut m, ts| {
|
|
||||||
ts.tags.iter().for_each(|t| {
|
|
||||||
m.entry(t.clone()).and_modify(|c| *c += 1).or_insert(1);
|
|
||||||
});
|
|
||||||
m
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
HashMap::new()
|
|
||||||
};
|
|
||||||
let tags = nm
|
|
||||||
.tags()?
|
|
||||||
.into_iter()
|
|
||||||
.map(|tag| {
|
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
tag.hash(&mut hasher);
|
|
||||||
let hex = format!("#{:06x}", hasher.finish() % (1 << 24));
|
|
||||||
let unread = if needs_unread {
|
|
||||||
*unread_msg_cnt.get(&tag).unwrap_or(&0)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
Tag {
|
|
||||||
name: tag,
|
|
||||||
fg_color: "white".to_string(),
|
|
||||||
bg_color: hex,
|
|
||||||
unread,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
info!("Fetching tags took {} seconds", now.elapsed().as_secs_f32());
|
|
||||||
Ok(tags)
|
Ok(tags)
|
||||||
}
|
}
|
||||||
async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result<Thread, Error> {
|
async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result<Thread, Error> {
|
||||||
// TODO(wathiede): normalize all email addresses through an address book with preferred
|
|
||||||
// display names (that default to the most commonly seen name).
|
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
let debug_content_tree = ctx
|
let debug_content_tree = ctx
|
||||||
.look_ahead()
|
.look_ahead()
|
||||||
.field("messages")
|
.field("messages")
|
||||||
.field("body")
|
.field("body")
|
||||||
.field("contentTree")
|
.field("contentTree")
|
||||||
.exists();
|
.exists();
|
||||||
let mut messages = Vec::new();
|
// TODO: look at thread_id and conditionally load newsreader
|
||||||
for (path, id) in std::iter::zip(nm.files(&thread_id)?, nm.message_ids(&thread_id)?) {
|
if newsreader::is_newsreader_thread(&thread_id) {
|
||||||
let tags = nm.tags_for_query(&format!("id:{id}"))?;
|
Ok(newsreader::thread(pool, thread_id).await?)
|
||||||
let file = File::open(&path)?;
|
|
||||||
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
|
||||||
let m = parse_mail(&mmap)?;
|
|
||||||
let from = email_addresses(&path, &m, "from")?;
|
|
||||||
let from = match from.len() {
|
|
||||||
0 => None,
|
|
||||||
1 => from.into_iter().next(),
|
|
||||||
_ => {
|
|
||||||
warn!(
|
|
||||||
"Got {} from addresses in message, truncating: {:?}",
|
|
||||||
from.len(),
|
|
||||||
from
|
|
||||||
);
|
|
||||||
from.into_iter().next()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let to = email_addresses(&path, &m, "to")?;
|
|
||||||
let cc = email_addresses(&path, &m, "cc")?;
|
|
||||||
let subject = m.headers.get_first_value("subject");
|
|
||||||
let timestamp = m
|
|
||||||
.headers
|
|
||||||
.get_first_value("date")
|
|
||||||
.and_then(|d| mailparse::dateparse(&d).ok());
|
|
||||||
let cid_prefix = shared::urls::cid_prefix(None, &id);
|
|
||||||
let body = match extract_body(&m, &id)? {
|
|
||||||
Body::PlainText(PlainText { text, content_tree }) => {
|
|
||||||
let text = if text.len() > MAX_RAW_MESSAGE_SIZE {
|
|
||||||
format!(
|
|
||||||
"{}...\n\nMESSAGE WAS TRUNCATED @ {} bytes",
|
|
||||||
&text[..MAX_RAW_MESSAGE_SIZE],
|
|
||||||
MAX_RAW_MESSAGE_SIZE
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
text
|
Ok(nm::thread(nm, thread_id, debug_content_tree).await?)
|
||||||
};
|
|
||||||
|
|
||||||
Body::Html(Html {
|
|
||||||
html: format!(
|
|
||||||
r#"<p class="view-part-text-plain">{}</p>"#,
|
|
||||||
// Trim newlines to prevent excessive white space at the beginning/end of
|
|
||||||
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
|
||||||
// header on the first line.
|
|
||||||
sanitize_html(&linkify_html(&text.trim_matches('\n')), &cid_prefix)?
|
|
||||||
),
|
|
||||||
content_tree: if debug_content_tree {
|
|
||||||
render_content_type_tree(&m)
|
|
||||||
} else {
|
|
||||||
content_tree
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
Body::Html(Html { html, content_tree }) => Body::Html(Html {
|
|
||||||
html: sanitize_html(&html, &cid_prefix)?,
|
|
||||||
content_tree: if debug_content_tree {
|
|
||||||
render_content_type_tree(&m)
|
|
||||||
} else {
|
|
||||||
content_tree
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
Body::UnhandledContentType(UnhandledContentType { content_tree, .. }) => {
|
|
||||||
let body_start = mmap
|
|
||||||
.windows(2)
|
|
||||||
.take(20_000)
|
|
||||||
.position(|w| w == b"\n\n")
|
|
||||||
.unwrap_or(0);
|
|
||||||
let body = mmap[body_start + 2..].to_vec();
|
|
||||||
Body::UnhandledContentType(UnhandledContentType {
|
|
||||||
text: String::from_utf8(body)?,
|
|
||||||
content_tree: if debug_content_tree {
|
|
||||||
render_content_type_tree(&m)
|
|
||||||
} else {
|
|
||||||
content_tree
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let headers = m
|
|
||||||
.headers
|
|
||||||
.iter()
|
|
||||||
.map(|h| Header {
|
|
||||||
key: h.get_key(),
|
|
||||||
value: h.get_value(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
// TODO(wathiede): parse message and fill out attachments
|
|
||||||
let attachments = extract_attachments(&m, &id)?;
|
|
||||||
messages.push(Message {
|
|
||||||
id,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
subject,
|
|
||||||
tags,
|
|
||||||
timestamp,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
path,
|
|
||||||
attachments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
messages.reverse();
|
|
||||||
// Find the first subject that's set. After reversing the vec, this should be the oldest
|
|
||||||
// message.
|
|
||||||
let subject: String = messages
|
|
||||||
.iter()
|
|
||||||
.skip_while(|m| m.subject.is_none())
|
|
||||||
.next()
|
|
||||||
.and_then(|m| m.subject.clone())
|
|
||||||
.unwrap_or("(NO SUBJECT)".to_string());
|
|
||||||
Ok(Thread {
|
|
||||||
thread_id,
|
|
||||||
subject,
|
|
||||||
messages,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -475,506 +306,3 @@ impl Mutation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>;
|
pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
||||||
pub struct Disposition {
|
|
||||||
pub r#type: DispositionType,
|
|
||||||
pub filename: Option<String>,
|
|
||||||
pub size: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)]
|
|
||||||
pub enum DispositionType {
|
|
||||||
Inline,
|
|
||||||
Attachment,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<mailparse::DispositionType> for DispositionType {
|
|
||||||
fn from(value: mailparse::DispositionType) -> Self {
|
|
||||||
match value {
|
|
||||||
mailparse::DispositionType::Inline => DispositionType::Inline,
|
|
||||||
mailparse::DispositionType::Attachment => DispositionType::Attachment,
|
|
||||||
dt => panic!("unhandled DispositionType {dt:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DispositionType {
|
|
||||||
fn default() -> Self {
|
|
||||||
DispositionType::Attachment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_body(m: &ParsedMail, id: &str) -> Result<Body, Error> {
|
|
||||||
let mut part_addr = Vec::new();
|
|
||||||
part_addr.push(id.to_string());
|
|
||||||
let body = m.get_body()?;
|
|
||||||
let ret = match m.ctype.mimetype.as_str() {
|
|
||||||
TEXT_PLAIN => return Ok(Body::text(body)),
|
|
||||||
TEXT_HTML => return Ok(Body::html(body)),
|
|
||||||
MULTIPART_MIXED => extract_mixed(m, &mut part_addr),
|
|
||||||
MULTIPART_ALTERNATIVE => extract_alternative(m, &mut part_addr),
|
|
||||||
MULTIPART_RELATED => extract_related(m, &mut part_addr),
|
|
||||||
_ => extract_unhandled(m),
|
|
||||||
};
|
|
||||||
if let Err(err) = ret {
|
|
||||||
error!("Failed to extract body: {err:?}");
|
|
||||||
return Ok(extract_unhandled(m)?);
|
|
||||||
}
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_unhandled(m: &ParsedMail) -> Result<Body, Error> {
|
|
||||||
let msg = format!(
|
|
||||||
"Unhandled body content type:\n{}\n{}",
|
|
||||||
render_content_type_tree(m),
|
|
||||||
m.get_body()?,
|
|
||||||
);
|
|
||||||
Ok(Body::UnhandledContentType(UnhandledContentType {
|
|
||||||
text: msg,
|
|
||||||
content_tree: render_content_type_tree(m),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// multipart/alternative defines multiple representations of the same message, and clients should
|
|
||||||
// show the fanciest they can display. For this program, the priority is text/html, text/plain,
|
|
||||||
// then give up.
|
|
||||||
fn extract_alternative(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Error> {
|
|
||||||
let handled_types = vec![
|
|
||||||
MULTIPART_ALTERNATIVE,
|
|
||||||
MULTIPART_MIXED,
|
|
||||||
MULTIPART_RELATED,
|
|
||||||
TEXT_HTML,
|
|
||||||
TEXT_PLAIN,
|
|
||||||
];
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype.as_str() == MULTIPART_ALTERNATIVE {
|
|
||||||
return extract_alternative(sp, part_addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype.as_str() == MULTIPART_MIXED {
|
|
||||||
return extract_related(sp, part_addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype.as_str() == MULTIPART_RELATED {
|
|
||||||
return extract_related(sp, part_addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype.as_str() == TEXT_HTML {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
return Ok(Body::html(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype.as_str() == TEXT_PLAIN {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
return Ok(Body::text(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(format!(
|
|
||||||
"extract_alternative failed to find suitable subpart, searched: {:?}",
|
|
||||||
handled_types
|
|
||||||
)
|
|
||||||
.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
// multipart/mixed defines multiple types of context all of which should be presented to the user
|
|
||||||
// 'serially'.
|
|
||||||
fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Error> {
|
|
||||||
let handled_types = vec![
|
|
||||||
MULTIPART_ALTERNATIVE,
|
|
||||||
MULTIPART_RELATED,
|
|
||||||
TEXT_HTML,
|
|
||||||
TEXT_PLAIN,
|
|
||||||
IMAGE_JPEG,
|
|
||||||
IMAGE_PNG,
|
|
||||||
];
|
|
||||||
let mut unhandled_types: Vec<_> = m
|
|
||||||
.subparts
|
|
||||||
.iter()
|
|
||||||
.map(|sp| sp.ctype.mimetype.as_str())
|
|
||||||
.filter(|mt| !handled_types.contains(&mt))
|
|
||||||
.collect();
|
|
||||||
unhandled_types.sort();
|
|
||||||
if !unhandled_types.is_empty() {
|
|
||||||
warn!("{MULTIPART_MIXED} contains the following unhandled mimetypes {unhandled_types:?}");
|
|
||||||
}
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
for (idx, sp) in m.subparts.iter().enumerate() {
|
|
||||||
part_addr.push(idx.to_string());
|
|
||||||
match sp.ctype.mimetype.as_str() {
|
|
||||||
MULTIPART_RELATED => parts.push(extract_related(sp, part_addr)?),
|
|
||||||
MULTIPART_ALTERNATIVE => parts.push(extract_alternative(sp, part_addr)?),
|
|
||||||
TEXT_PLAIN => parts.push(Body::text(sp.get_body()?)),
|
|
||||||
TEXT_HTML => parts.push(Body::html(sp.get_body()?)),
|
|
||||||
IMAGE_JPEG | IMAGE_PNG => {
|
|
||||||
let pcd = sp.get_content_disposition();
|
|
||||||
let filename = pcd
|
|
||||||
.params
|
|
||||||
.get("filename")
|
|
||||||
.map(|s| s.clone())
|
|
||||||
.unwrap_or("".to_string());
|
|
||||||
// Only add inline images, attachments are handled as an attribute of the top level Message and rendered separate client-side.
|
|
||||||
if pcd.disposition == mailparse::DispositionType::Inline {
|
|
||||||
parts.push(Body::html(format!(
|
|
||||||
r#"<img src="/view/attachment/{}/{}/{filename}">"#,
|
|
||||||
part_addr[0],
|
|
||||||
part_addr
|
|
||||||
.iter()
|
|
||||||
.skip(1)
|
|
||||||
.map(|i| i.to_string())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(".")
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
part_addr.pop();
|
|
||||||
}
|
|
||||||
Ok(flatten_body_parts(&parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flatten_body_parts(parts: &[Body]) -> Body {
|
|
||||||
let html = parts
|
|
||||||
.iter()
|
|
||||||
.map(|p| match p {
|
|
||||||
Body::PlainText(PlainText { text, .. }) => {
|
|
||||||
format!(
|
|
||||||
r#"<p class="view-part-text-plain">{}</p>"#,
|
|
||||||
// Trim newlines to prevent excessive white space at the beginning/end of
|
|
||||||
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
|
||||||
// header on the first line.
|
|
||||||
linkify_html(&text.trim_matches('\n'))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Body::Html(Html { html, .. }) => html.clone(),
|
|
||||||
Body::UnhandledContentType(UnhandledContentType { text, .. }) => {
|
|
||||||
error!("text len {}", text.len());
|
|
||||||
format!(
|
|
||||||
r#"<p class="view-part-unhandled">{}</p>"#,
|
|
||||||
// Trim newlines to prevent excessive white space at the beginning/end of
|
|
||||||
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
|
||||||
// header on the first line.
|
|
||||||
linkify_html(&text.trim_matches('\n'))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
info!("flatten_body_parts {} {html}", parts.len());
|
|
||||||
Body::html(html)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_related(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Error> {
|
|
||||||
// TODO(wathiede): collect related things and change return type to new Body arm.
|
|
||||||
let handled_types = vec![
|
|
||||||
MULTIPART_ALTERNATIVE,
|
|
||||||
TEXT_HTML,
|
|
||||||
TEXT_PLAIN,
|
|
||||||
IMAGE_JPEG,
|
|
||||||
IMAGE_PNG,
|
|
||||||
];
|
|
||||||
let mut unhandled_types: Vec<_> = m
|
|
||||||
.subparts
|
|
||||||
.iter()
|
|
||||||
.map(|sp| sp.ctype.mimetype.as_str())
|
|
||||||
.filter(|mt| !handled_types.contains(&mt))
|
|
||||||
.collect();
|
|
||||||
unhandled_types.sort();
|
|
||||||
if !unhandled_types.is_empty() {
|
|
||||||
warn!("{MULTIPART_RELATED} contains the following unhandled mimetypes {unhandled_types:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, sp) in m.subparts.iter().enumerate() {
|
|
||||||
if sp.ctype.mimetype == IMAGE_PNG || sp.ctype.mimetype == IMAGE_JPEG {
|
|
||||||
info!("sp.ctype {:#?}", sp.ctype);
|
|
||||||
//info!("sp.headers {:#?}", sp.headers);
|
|
||||||
if let Some(cid) = sp.headers.get_first_value("Content-Id") {
|
|
||||||
let mut part_id = part_addr.clone();
|
|
||||||
part_id.push(i.to_string());
|
|
||||||
info!("cid: {cid} part_id {part_id:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == MULTIPART_ALTERNATIVE {
|
|
||||||
return extract_alternative(m, part_addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == TEXT_HTML {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
return Ok(Body::html(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
if sp.ctype.mimetype == TEXT_PLAIN {
|
|
||||||
let body = sp.get_body()?;
|
|
||||||
return Ok(Body::text(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(format!(
|
|
||||||
"extract_related failed to find suitable subpart, searched: {:?}",
|
|
||||||
handled_types
|
|
||||||
)
|
|
||||||
.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk_attachments<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
|
|
||||||
m: &ParsedMail,
|
|
||||||
visitor: F,
|
|
||||||
) -> Option<T> {
|
|
||||||
let mut cur_addr = Vec::new();
|
|
||||||
walk_attachments_inner(m, visitor, &mut cur_addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk_attachments_inner<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
|
|
||||||
m: &ParsedMail,
|
|
||||||
visitor: F,
|
|
||||||
cur_addr: &mut Vec<usize>,
|
|
||||||
) -> Option<T> {
|
|
||||||
for (idx, sp) in m.subparts.iter().enumerate() {
|
|
||||||
cur_addr.push(idx);
|
|
||||||
let val = visitor(sp, &cur_addr);
|
|
||||||
if val.is_some() {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
let val = walk_attachments_inner(sp, visitor, cur_addr);
|
|
||||||
if val.is_some() {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
cur_addr.pop();
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(wathiede): make this walk_attachments that takes a closure.
|
|
||||||
// Then implement one closure for building `Attachment` and imlement another that can be used to
|
|
||||||
// get the bytes for serving attachments of HTTP
|
|
||||||
fn extract_attachments(m: &ParsedMail, id: &str) -> Result<Vec<Attachment>, Error> {
|
|
||||||
let mut attachments = Vec::new();
|
|
||||||
for (idx, sp) in m.subparts.iter().enumerate() {
|
|
||||||
if let Some(attachment) = extract_attachment(sp, id, &[idx]) {
|
|
||||||
// Filter out inline attachements, they're flattened into the body of the message.
|
|
||||||
if attachment.disposition == DispositionType::Attachment {
|
|
||||||
attachments.push(attachment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(attachments)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_attachment(m: &ParsedMail, id: &str, idx: &[usize]) -> Option<Attachment> {
|
|
||||||
let pcd = m.get_content_disposition();
|
|
||||||
// TODO: do we need to handle empty filename attachments, or should we change the definition of
|
|
||||||
// Attachment::filename?
|
|
||||||
let Some(filename) = pcd.params.get("filename").map(|f| f.clone()) else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: grab this from somewhere
|
|
||||||
let content_id = None;
|
|
||||||
let bytes = match m.get_body_raw() {
|
|
||||||
Ok(bytes) => bytes,
|
|
||||||
Err(err) => {
|
|
||||||
error!("failed to get body for attachment: {err}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return Some(Attachment {
|
|
||||||
id: id.to_string(),
|
|
||||||
idx: idx
|
|
||||||
.iter()
|
|
||||||
.map(|i| i.to_string())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("."),
|
|
||||||
disposition: pcd.disposition.into(),
|
|
||||||
filename: Some(filename),
|
|
||||||
size: bytes.len(),
|
|
||||||
// TODO: what is the default for ctype?
|
|
||||||
// TODO: do we want to use m.ctype.params for anything?
|
|
||||||
content_type: Some(m.ctype.mimetype.clone()),
|
|
||||||
content_id,
|
|
||||||
bytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_attachment_filename(header_value: &str) -> &str {
|
|
||||||
info!("get_attachment_filename {header_value}");
|
|
||||||
// Strip last "
|
|
||||||
let v = &header_value[..header_value.len() - 1];
|
|
||||||
if let Some(idx) = v.rfind('"') {
|
|
||||||
&v[idx + 1..]
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_content_type<'a>(headers: &[MailHeader<'a>]) -> Option<String> {
|
|
||||||
if let Some(v) = headers.get_first_value("Content-Type") {
|
|
||||||
if let Some(idx) = v.find(';') {
|
|
||||||
return Some(v[..idx].to_string());
|
|
||||||
} else {
|
|
||||||
return Some(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_content_id<'a>(headers: &[MailHeader<'a>]) -> Option<String> {
|
|
||||||
headers.get_first_value("Content-Id")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_content_type_tree(m: &ParsedMail) -> String {
|
|
||||||
const WIDTH: usize = 4;
|
|
||||||
const SKIP_HEADERS: [&str; 4] = [
|
|
||||||
"Authentication-Results",
|
|
||||||
"DKIM-Signature",
|
|
||||||
"Received",
|
|
||||||
"Received-SPF",
|
|
||||||
];
|
|
||||||
fn render_ct_rec(m: &ParsedMail, depth: usize) -> String {
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
|
|
||||||
parts.push(msg);
|
|
||||||
for sp in &m.subparts {
|
|
||||||
parts.push(render_ct_rec(sp, depth + 1))
|
|
||||||
}
|
|
||||||
parts.join("\n")
|
|
||||||
}
|
|
||||||
fn render_rec(m: &ParsedMail, depth: usize) -> String {
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
|
|
||||||
parts.push(msg);
|
|
||||||
let indent = " ".repeat(depth * WIDTH);
|
|
||||||
if !m.ctype.charset.is_empty() {
|
|
||||||
parts.push(format!("{indent} Character Set: {}", m.ctype.charset));
|
|
||||||
}
|
|
||||||
for (k, v) in m.ctype.params.iter() {
|
|
||||||
parts.push(format!("{indent} {k}: {v}"));
|
|
||||||
}
|
|
||||||
if !m.headers.is_empty() {
|
|
||||||
parts.push(format!("{indent} == headers =="));
|
|
||||||
for h in &m.headers {
|
|
||||||
if h.get_key().starts_with('X') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if SKIP_HEADERS.contains(&h.get_key().as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push(format!("{indent} {}: {}", h.get_key_ref(), h.get_value()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sp in &m.subparts {
|
|
||||||
parts.push(render_rec(sp, depth + 1))
|
|
||||||
}
|
|
||||||
parts.join("\n")
|
|
||||||
}
|
|
||||||
format!(
|
|
||||||
"Outline:\n{}\n\nDetailed:\n{}\n\nNot showing headers:\n {}\n X.*",
|
|
||||||
render_ct_rec(m, 1),
|
|
||||||
render_rec(m, 1),
|
|
||||||
SKIP_HEADERS.join("\n ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn email_addresses(path: &str, m: &ParsedMail, header_name: &str) -> Result<Vec<Email>, Error> {
|
|
||||||
let mut addrs = Vec::new();
|
|
||||||
for header_value in m.headers.get_all_values(header_name) {
|
|
||||||
match mailparse::addrparse(&header_value) {
|
|
||||||
Ok(mal) => {
|
|
||||||
for ma in mal.into_inner() {
|
|
||||||
match ma {
|
|
||||||
mailparse::MailAddr::Group(gi) => {
|
|
||||||
if !gi.group_name.contains("ndisclosed") {
|
|
||||||
println!("[{path}][{header_name}] Group: {gi}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mailparse::MailAddr::Single(s) => addrs.push(Email {
|
|
||||||
name: s.display_name,
|
|
||||||
addr: Some(s.addr),
|
|
||||||
}), //println!("Single: {s}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
let v = header_value;
|
|
||||||
if v.matches('@').count() == 1 {
|
|
||||||
if v.matches('<').count() == 1 && v.ends_with('>') {
|
|
||||||
let idx = v.find('<').unwrap();
|
|
||||||
let addr = &v[idx + 1..v.len() - 1].trim();
|
|
||||||
let name = &v[..idx].trim();
|
|
||||||
addrs.push(Email {
|
|
||||||
name: Some(name.to_string()),
|
|
||||||
addr: Some(addr.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addrs.push(Email {
|
|
||||||
name: Some(v),
|
|
||||||
addr: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(addrs)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cid_attachment_bytes(nm: &Notmuch, id: &str, cid: &str) -> Result<Attachment, ServerError> {
|
|
||||||
let files = nm.files(id)?;
|
|
||||||
let Some(path) = files.first() else {
|
|
||||||
warn!("failed to find files for message {id}");
|
|
||||||
return Err(ServerError::PartNotFound);
|
|
||||||
};
|
|
||||||
let file = File::open(&path)?;
|
|
||||||
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
|
||||||
let m = parse_mail(&mmap)?;
|
|
||||||
if let Some(attachment) = walk_attachments(&m, |sp, _cur_idx| {
|
|
||||||
info!("{cid} {:?}", get_content_id(&sp.headers));
|
|
||||||
if let Some(h_cid) = get_content_id(&sp.headers) {
|
|
||||||
let h_cid = &h_cid[1..h_cid.len() - 1];
|
|
||||||
if h_cid == cid {
|
|
||||||
let attachment = extract_attachment(&sp, id, &[]).unwrap_or(Attachment {
|
|
||||||
..Attachment::default()
|
|
||||||
});
|
|
||||||
return Some(attachment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}) {
|
|
||||||
return Ok(attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ServerError::PartNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn attachment_bytes(nm: &Notmuch, id: &str, idx: &[usize]) -> Result<Attachment, ServerError> {
|
|
||||||
let files = nm.files(id)?;
|
|
||||||
let Some(path) = files.first() else {
|
|
||||||
warn!("failed to find files for message {id}");
|
|
||||||
return Err(ServerError::PartNotFound);
|
|
||||||
};
|
|
||||||
let file = File::open(&path)?;
|
|
||||||
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
|
||||||
let m = parse_mail(&mmap)?;
|
|
||||||
if let Some(attachment) = walk_attachments(&m, |sp, cur_idx| {
|
|
||||||
if cur_idx == idx {
|
|
||||||
let attachment = extract_attachment(&sp, id, idx).unwrap_or(Attachment {
|
|
||||||
..Attachment::default()
|
|
||||||
});
|
|
||||||
return Some(attachment);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}) {
|
|
||||||
return Ok(attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(ServerError::PartNotFound)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
|
pub mod newsreader;
|
||||||
pub mod nm;
|
pub mod nm;
|
||||||
|
|
||||||
use css_inline::{CSSInliner, InlineError, InlineOptions};
|
use css_inline::{CSSInliner, InlineError, InlineOptions};
|
||||||
@ -8,6 +9,7 @@ use log::error;
|
|||||||
use lol_html::{element, errors::RewritingError, rewrite_str, RewriteStrSettings};
|
use lol_html::{element, errors::RewritingError, rewrite_str, RewriteStrSettings};
|
||||||
use maplit::{hashmap, hashset};
|
use maplit::{hashmap, hashset};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum SanitizeError {
|
pub enum SanitizeError {
|
||||||
@ -45,7 +47,11 @@ pub fn linkify_html(text: &str) -> String {
|
|||||||
|
|
||||||
// html contains the content to be cleaned, and cid_prefix is used to resolve mixed part image
|
// html contains the content to be cleaned, and cid_prefix is used to resolve mixed part image
|
||||||
// referrences
|
// referrences
|
||||||
pub fn sanitize_html(html: &str, cid_prefix: &str) -> Result<String, SanitizeError> {
|
pub fn sanitize_html(
|
||||||
|
html: &str,
|
||||||
|
cid_prefix: &str,
|
||||||
|
base_url: &Url,
|
||||||
|
) -> Result<String, SanitizeError> {
|
||||||
let element_content_handlers = vec![
|
let element_content_handlers = vec![
|
||||||
// Open links in new tab
|
// Open links in new tab
|
||||||
element!("a[href]", |el| {
|
element!("a[href]", |el| {
|
||||||
@ -53,6 +59,22 @@ pub fn sanitize_html(html: &str, cid_prefix: &str) -> Result<String, SanitizeErr
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}),
|
}),
|
||||||
|
// Make links with relative URLs absolute
|
||||||
|
element!("a[href]", |el| {
|
||||||
|
if let Some(Ok(href)) = el.get_attribute("href").map(|href| base_url.join(&href)) {
|
||||||
|
el.set_attribute("href", &href.as_str()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
// Make images with relative srcs absolute
|
||||||
|
element!("img[src]", |el| {
|
||||||
|
if let Some(Ok(src)) = el.get_attribute("src").map(|src| base_url.join(&src)) {
|
||||||
|
el.set_attribute("src", &src.as_str()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
// Replace mixed part CID images with URL
|
// Replace mixed part CID images with URL
|
||||||
element!("img[src]", |el| {
|
element!("img[src]", |el| {
|
||||||
let src = el
|
let src = el
|
||||||
|
|||||||
281
server/src/newsreader.rs
Normal file
281
server/src/newsreader.rs
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
use std::{
|
||||||
|
convert::Infallible,
|
||||||
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
|
str::FromStr,
|
||||||
|
};
|
||||||
|
|
||||||
|
use async_graphql::connection::{self, Connection, Edge};
|
||||||
|
use log::info;
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
const TAG_PREFIX: &'static str = "News/";
|
||||||
|
const THREAD_PREFIX: &'static str = "news:";
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::ServerError,
|
||||||
|
graphql::{Body, Email, Html, Message, Tag, Thread, ThreadSummary},
|
||||||
|
sanitize_html,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn is_newsreader_search(query: &str) -> bool {
|
||||||
|
query.contains(TAG_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_newsreader_thread(query: &str) -> bool {
|
||||||
|
query.starts_with(THREAD_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count(pool: &PgPool, query: &str) -> Result<usize, ServerError> {
|
||||||
|
let query: Query = query.parse()?;
|
||||||
|
let site = query.site.expect("search has no site");
|
||||||
|
let row = sqlx::query_file!("sql/count.sql", site, query.unread_only)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.count.unwrap_or(0).try_into().unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
pool: &PgPool,
|
||||||
|
after: Option<String>,
|
||||||
|
before: Option<String>,
|
||||||
|
first: Option<i32>,
|
||||||
|
last: Option<i32>,
|
||||||
|
query: String,
|
||||||
|
) -> Result<Connection<usize, ThreadSummary>, async_graphql::Error> {
|
||||||
|
let query: Query = query.parse()?;
|
||||||
|
info!("news search query {query:?}");
|
||||||
|
let site = query.site.expect("search has no site");
|
||||||
|
connection::query(
|
||||||
|
after,
|
||||||
|
before,
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
|after: Option<usize>, before: Option<usize>, first, last| async move {
|
||||||
|
info!("search page info {after:#?}, {before:#?}, {first:#?}, {last:#?}");
|
||||||
|
let default_page_size = 100;
|
||||||
|
let (offset, limit) = match (after, before, first, last) {
|
||||||
|
// Reasonable defaults
|
||||||
|
(None, None, None, None) => (0, default_page_size),
|
||||||
|
(None, None, Some(first), None) => (0, first),
|
||||||
|
(Some(after), None, None, None) => (after, default_page_size),
|
||||||
|
(Some(after), None, Some(first), None) => (after, first),
|
||||||
|
(None, Some(before), None, None) => {
|
||||||
|
(before.saturating_sub(default_page_size), default_page_size)
|
||||||
|
}
|
||||||
|
(None, Some(before), None, Some(last)) => (before.saturating_sub(last), last),
|
||||||
|
(None, None, None, Some(_)) => {
|
||||||
|
panic!("specifying last and no before doesn't make sense")
|
||||||
|
}
|
||||||
|
(None, None, Some(_), Some(_)) => {
|
||||||
|
panic!("specifying first and last doesn't make sense")
|
||||||
|
}
|
||||||
|
(None, Some(_), Some(_), _) => {
|
||||||
|
panic!("specifying before and first doesn't make sense")
|
||||||
|
}
|
||||||
|
(Some(_), Some(_), _, _) => {
|
||||||
|
panic!("specifying after and before doesn't make sense")
|
||||||
|
}
|
||||||
|
(Some(_), None, None, Some(_)) => {
|
||||||
|
panic!("specifying after and last doesn't make sense")
|
||||||
|
}
|
||||||
|
(Some(_), None, Some(_), Some(_)) => {
|
||||||
|
panic!("specifying after, first and last doesn't make sense")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The +1 is to see if there are more pages of data available.
|
||||||
|
let limit = limit + 1;
|
||||||
|
|
||||||
|
info!("search page offset {offset} limit {limit}");
|
||||||
|
let rows = sqlx::query_file!(
|
||||||
|
"sql/threads.sql",
|
||||||
|
site,
|
||||||
|
query.unread_only,
|
||||||
|
offset as i64,
|
||||||
|
limit as i64
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut slice = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let tags = if r.is_read.unwrap_or(false) {
|
||||||
|
vec![site.clone()]
|
||||||
|
} else {
|
||||||
|
vec!["unread".to_string(), site.clone()]
|
||||||
|
};
|
||||||
|
ThreadSummary {
|
||||||
|
thread: format!("{THREAD_PREFIX}{}", r.uid),
|
||||||
|
timestamp: r
|
||||||
|
.date
|
||||||
|
.expect("post missing date")
|
||||||
|
.assume_utc()
|
||||||
|
.unix_timestamp() as isize,
|
||||||
|
date_relative: "TODO date_relative".to_string(),
|
||||||
|
matched: 0,
|
||||||
|
total: 1,
|
||||||
|
authors: r.name.unwrap_or_else(|| site.clone()),
|
||||||
|
subject: r.title.unwrap_or("NO TITLE".to_string()),
|
||||||
|
tags,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let has_more = slice.len() == limit;
|
||||||
|
let mut connection = Connection::new(offset > 0, has_more);
|
||||||
|
if has_more {
|
||||||
|
slice.pop();
|
||||||
|
};
|
||||||
|
connection.edges.extend(
|
||||||
|
slice
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, item)| Edge::new(offset + idx, item)),
|
||||||
|
);
|
||||||
|
Ok::<_, async_graphql::Error>(connection)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tags(pool: &PgPool, _needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
||||||
|
// TODO: optimize query by using needs_unread
|
||||||
|
let tags = sqlx::query_file!("sql/tags.sql").fetch_all(pool).await?;
|
||||||
|
let tags = tags
|
||||||
|
.into_iter()
|
||||||
|
.map(|tag| {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
tag.site.hash(&mut hasher);
|
||||||
|
let hex = format!("#{:06x}", hasher.finish() % (1 << 24));
|
||||||
|
let unread = tag.unread.unwrap_or(0).try_into().unwrap_or(0);
|
||||||
|
let name = format!("{TAG_PREFIX}{}", tag.site.expect("tag must have site"));
|
||||||
|
Tag {
|
||||||
|
name,
|
||||||
|
fg_color: "white".to_string(),
|
||||||
|
bg_color: hex,
|
||||||
|
unread,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn thread(pool: &PgPool, thread_id: String) -> Result<Thread, ServerError> {
|
||||||
|
let id = thread_id
|
||||||
|
.strip_prefix(THREAD_PREFIX)
|
||||||
|
.expect("news thread doesn't start with '{THREAD_PREFIX}'")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let r = sqlx::query_file!("sql/thread.sql", id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let site = r.site.unwrap_or("NO SITE".to_string());
|
||||||
|
let tags = if r.is_read.unwrap_or(false) {
|
||||||
|
vec![site.clone()]
|
||||||
|
} else {
|
||||||
|
vec!["unread".to_string(), site.clone()]
|
||||||
|
};
|
||||||
|
let default_homepage = "http://no-homepage";
|
||||||
|
let homepage = Url::parse(
|
||||||
|
&r.homepage
|
||||||
|
.map(|h| {
|
||||||
|
if h.is_empty() {
|
||||||
|
default_homepage.to_string()
|
||||||
|
} else {
|
||||||
|
h
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(default_homepage.to_string()),
|
||||||
|
)?;
|
||||||
|
let link = Url::parse(
|
||||||
|
&r.link
|
||||||
|
.as_ref()
|
||||||
|
.map(|h| {
|
||||||
|
if h.is_empty() {
|
||||||
|
default_homepage.to_string()
|
||||||
|
} else {
|
||||||
|
h.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(default_homepage.to_string()),
|
||||||
|
)?;
|
||||||
|
let addr = r.link.as_ref().map(|link| {
|
||||||
|
if link.contains('@') {
|
||||||
|
link.clone()
|
||||||
|
} else {
|
||||||
|
if let Ok(url) = homepage.join(&link) {
|
||||||
|
url.to_string()
|
||||||
|
} else {
|
||||||
|
link.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let html = r.summary.unwrap_or("NO SUMMARY".to_string());
|
||||||
|
// TODO: add site specific cleanups. For example:
|
||||||
|
// * Grafana does <div class="image-wrapp"><img class="lazyload>"<img src="/media/...>"</img></div>
|
||||||
|
// * Some sites appear to be HTML encoded, unencode them, i.e. imperialviolet
|
||||||
|
let html = sanitize_html(&html, "", &link)?;
|
||||||
|
let body = Body::Html(Html {
|
||||||
|
html,
|
||||||
|
content_tree: "".to_string(),
|
||||||
|
});
|
||||||
|
let title = r.title.unwrap_or("NO TITLE".to_string());
|
||||||
|
let from = Some(Email {
|
||||||
|
name: r.name,
|
||||||
|
addr: addr.map(|a| a.to_string()),
|
||||||
|
});
|
||||||
|
Ok(Thread {
|
||||||
|
thread_id,
|
||||||
|
subject: title.clone(),
|
||||||
|
messages: vec![Message {
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
to: Vec::new(),
|
||||||
|
cc: Vec::new(),
|
||||||
|
subject: Some(title),
|
||||||
|
timestamp: Some(
|
||||||
|
r.date
|
||||||
|
.expect("post missing date")
|
||||||
|
.assume_utc()
|
||||||
|
.unix_timestamp(),
|
||||||
|
),
|
||||||
|
headers: Vec::new(),
|
||||||
|
body,
|
||||||
|
path: "".to_string(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
tags,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Query {
|
||||||
|
unread_only: bool,
|
||||||
|
site: Option<String>,
|
||||||
|
remainder: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Query {
|
||||||
|
type Err = Infallible;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let mut unread_only = false;
|
||||||
|
let mut site = None;
|
||||||
|
let mut remainder = Vec::new();
|
||||||
|
let site_prefix = format!("tag:{TAG_PREFIX}");
|
||||||
|
for word in s.split_whitespace() {
|
||||||
|
if word == "is:unread" {
|
||||||
|
unread_only = true
|
||||||
|
} else if word.starts_with(&site_prefix) {
|
||||||
|
site = Some(word[site_prefix.len()..].to_string())
|
||||||
|
} else {
|
||||||
|
remainder.push(word.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Query {
|
||||||
|
unread_only,
|
||||||
|
site,
|
||||||
|
remainder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
751
server/src/nm.rs
751
server/src/nm.rs
@ -1,13 +1,754 @@
|
|||||||
use shared::Message;
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs::File,
|
||||||
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::error;
|
use async_graphql::connection::{self, Connection, Edge};
|
||||||
|
use log::{error, info, warn};
|
||||||
|
use mailparse::{parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
||||||
|
use memmap::MmapOptions;
|
||||||
|
use notmuch::Notmuch;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::ServerError,
|
||||||
|
graphql::{
|
||||||
|
Attachment, Body, DispositionType, Email, Header, Html, Message, PlainText, Tag, Thread,
|
||||||
|
ThreadSummary, UnhandledContentType,
|
||||||
|
},
|
||||||
|
linkify_html, sanitize_html,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXT_PLAIN: &'static str = "text/plain";
|
||||||
|
const TEXT_HTML: &'static str = "text/html";
|
||||||
|
const IMAGE_JPEG: &'static str = "image/jpeg";
|
||||||
|
const IMAGE_PNG: &'static str = "image/png";
|
||||||
|
const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative";
|
||||||
|
const MULTIPART_MIXED: &'static str = "multipart/mixed";
|
||||||
|
const MULTIPART_RELATED: &'static str = "multipart/related";
|
||||||
|
|
||||||
|
const MAX_RAW_MESSAGE_SIZE: usize = 100_000;
|
||||||
|
|
||||||
// TODO(wathiede): decide good error type
|
// TODO(wathiede): decide good error type
|
||||||
pub fn threadset_to_messages(
|
pub fn threadset_to_messages(thread_set: notmuch::ThreadSet) -> Result<Vec<Message>, ServerError> {
|
||||||
thread_set: notmuch::ThreadSet,
|
|
||||||
) -> Result<Vec<Message>, error::ServerError> {
|
|
||||||
for t in thread_set.0 {
|
for t in thread_set.0 {
|
||||||
for _tn in t.0 {}
|
for _tn in t.0 {}
|
||||||
}
|
}
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn count(nm: &Notmuch, query: &str) -> Result<usize, ServerError> {
|
||||||
|
Ok(nm.count(query)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
nm: &Notmuch,
|
||||||
|
after: Option<String>,
|
||||||
|
before: Option<String>,
|
||||||
|
first: Option<i32>,
|
||||||
|
last: Option<i32>,
|
||||||
|
query: String,
|
||||||
|
) -> Result<Connection<usize, ThreadSummary>, async_graphql::Error> {
|
||||||
|
connection::query(
|
||||||
|
after,
|
||||||
|
before,
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
|after, before, first, last| async move {
|
||||||
|
let total = nm.count(&query)?;
|
||||||
|
let (first, last) = if let (None, None) = (first, last) {
|
||||||
|
info!("neither first nor last set, defaulting first to 20");
|
||||||
|
(Some(20), None)
|
||||||
|
} else {
|
||||||
|
(first, last)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut start = after.map(|after| after + 1).unwrap_or(0);
|
||||||
|
let mut end = before.unwrap_or(total);
|
||||||
|
if let Some(first) = first {
|
||||||
|
end = (start + first).min(end);
|
||||||
|
}
|
||||||
|
if let Some(last) = last {
|
||||||
|
start = if last > end - start { end } else { end - last };
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = end - start;
|
||||||
|
let slice: Vec<ThreadSummary> = nm
|
||||||
|
.search(&query, start, count)?
|
||||||
|
.0
|
||||||
|
.into_iter()
|
||||||
|
.map(|ts| ThreadSummary {
|
||||||
|
thread: ts.thread,
|
||||||
|
timestamp: ts.timestamp,
|
||||||
|
date_relative: ts.date_relative,
|
||||||
|
matched: ts.matched,
|
||||||
|
total: ts.total,
|
||||||
|
authors: ts.authors,
|
||||||
|
subject: ts.subject,
|
||||||
|
tags: ts.tags,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut connection = Connection::new(start > 0, end < total);
|
||||||
|
connection.edges.extend(
|
||||||
|
slice
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, item)| Edge::new(start + idx, item)),
|
||||||
|
);
|
||||||
|
Ok::<_, async_graphql::Error>(connection)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let unread_msg_cnt: HashMap<String, usize> = if needs_unread {
|
||||||
|
// 10000 is an arbitrary number, if there's more than 10k unread messages, we'll
|
||||||
|
// get an inaccurate count.
|
||||||
|
nm.search("is:unread", 0, 10000)?
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.fold(HashMap::new(), |mut m, ts| {
|
||||||
|
ts.tags.iter().for_each(|t| {
|
||||||
|
m.entry(t.clone()).and_modify(|c| *c += 1).or_insert(1);
|
||||||
|
});
|
||||||
|
m
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
let tags = nm
|
||||||
|
.tags()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|tag| {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
tag.hash(&mut hasher);
|
||||||
|
let hex = format!("#{:06x}", hasher.finish() % (1 << 24));
|
||||||
|
let unread = if needs_unread {
|
||||||
|
*unread_msg_cnt.get(&tag).unwrap_or(&0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
Tag {
|
||||||
|
name: tag,
|
||||||
|
fg_color: "white".to_string(),
|
||||||
|
bg_color: hex,
|
||||||
|
unread,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
info!("Fetching tags took {} seconds", now.elapsed().as_secs_f32());
|
||||||
|
Ok(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn thread(
|
||||||
|
nm: &Notmuch,
|
||||||
|
thread_id: String,
|
||||||
|
debug_content_tree: bool,
|
||||||
|
) -> Result<Thread, ServerError> {
|
||||||
|
// TODO(wathiede): normalize all email addresses through an address book with preferred
|
||||||
|
// display names (that default to the most commonly seen name).
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
for (path, id) in std::iter::zip(nm.files(&thread_id)?, nm.message_ids(&thread_id)?) {
|
||||||
|
let tags = nm.tags_for_query(&format!("id:{id}"))?;
|
||||||
|
let file = File::open(&path)?;
|
||||||
|
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
||||||
|
let m = parse_mail(&mmap)?;
|
||||||
|
let from = email_addresses(&path, &m, "from")?;
|
||||||
|
let from = match from.len() {
|
||||||
|
0 => None,
|
||||||
|
1 => from.into_iter().next(),
|
||||||
|
_ => {
|
||||||
|
warn!(
|
||||||
|
"Got {} from addresses in message, truncating: {:?}",
|
||||||
|
from.len(),
|
||||||
|
from
|
||||||
|
);
|
||||||
|
from.into_iter().next()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let to = email_addresses(&path, &m, "to")?;
|
||||||
|
let cc = email_addresses(&path, &m, "cc")?;
|
||||||
|
let subject = m.headers.get_first_value("subject");
|
||||||
|
let timestamp = m
|
||||||
|
.headers
|
||||||
|
.get_first_value("date")
|
||||||
|
.and_then(|d| mailparse::dateparse(&d).ok());
|
||||||
|
let cid_prefix = shared::urls::cid_prefix(None, &id);
|
||||||
|
let base_url = Url::parse("https://there-should-be-no-relative-urls-in-email").unwrap();
|
||||||
|
let body = match extract_body(&m, &id)? {
|
||||||
|
Body::PlainText(PlainText { text, content_tree }) => {
|
||||||
|
let text = if text.len() > MAX_RAW_MESSAGE_SIZE {
|
||||||
|
format!(
|
||||||
|
"{}...\n\nMESSAGE WAS TRUNCATED @ {} bytes",
|
||||||
|
&text[..MAX_RAW_MESSAGE_SIZE],
|
||||||
|
MAX_RAW_MESSAGE_SIZE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
};
|
||||||
|
|
||||||
|
Body::Html(Html {
|
||||||
|
html: format!(
|
||||||
|
r#"<p class="view-part-text-plain">{}</p>"#,
|
||||||
|
// Trim newlines to prevent excessive white space at the beginning/end of
|
||||||
|
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
||||||
|
// header on the first line.
|
||||||
|
sanitize_html(
|
||||||
|
&linkify_html(&text.trim_matches('\n')),
|
||||||
|
&cid_prefix,
|
||||||
|
&base_url
|
||||||
|
)?
|
||||||
|
),
|
||||||
|
content_tree: if debug_content_tree {
|
||||||
|
render_content_type_tree(&m)
|
||||||
|
} else {
|
||||||
|
content_tree
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Body::Html(Html { html, content_tree }) => Body::Html(Html {
|
||||||
|
html: sanitize_html(&html, &cid_prefix, &base_url)?,
|
||||||
|
content_tree: if debug_content_tree {
|
||||||
|
render_content_type_tree(&m)
|
||||||
|
} else {
|
||||||
|
content_tree
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
Body::UnhandledContentType(UnhandledContentType { content_tree, .. }) => {
|
||||||
|
let body_start = mmap
|
||||||
|
.windows(2)
|
||||||
|
.take(20_000)
|
||||||
|
.position(|w| w == b"\n\n")
|
||||||
|
.unwrap_or(0);
|
||||||
|
let body = mmap[body_start + 2..].to_vec();
|
||||||
|
Body::UnhandledContentType(UnhandledContentType {
|
||||||
|
text: String::from_utf8(body)?,
|
||||||
|
content_tree: if debug_content_tree {
|
||||||
|
render_content_type_tree(&m)
|
||||||
|
} else {
|
||||||
|
content_tree
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let headers = m
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.map(|h| Header {
|
||||||
|
key: h.get_key(),
|
||||||
|
value: h.get_value(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// TODO(wathiede): parse message and fill out attachments
|
||||||
|
let attachments = extract_attachments(&m, &id)?;
|
||||||
|
messages.push(Message {
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
subject,
|
||||||
|
tags,
|
||||||
|
timestamp,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
path,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
messages.reverse();
|
||||||
|
// Find the first subject that's set. After reversing the vec, this should be the oldest
|
||||||
|
// message.
|
||||||
|
let subject: String = messages
|
||||||
|
.iter()
|
||||||
|
.skip_while(|m| m.subject.is_none())
|
||||||
|
.next()
|
||||||
|
.and_then(|m| m.subject.clone())
|
||||||
|
.unwrap_or("(NO SUBJECT)".to_string());
|
||||||
|
Ok(Thread {
|
||||||
|
thread_id,
|
||||||
|
subject,
|
||||||
|
messages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn email_addresses(
|
||||||
|
path: &str,
|
||||||
|
m: &ParsedMail,
|
||||||
|
header_name: &str,
|
||||||
|
) -> Result<Vec<Email>, ServerError> {
|
||||||
|
let mut addrs = Vec::new();
|
||||||
|
for header_value in m.headers.get_all_values(header_name) {
|
||||||
|
match mailparse::addrparse(&header_value) {
|
||||||
|
Ok(mal) => {
|
||||||
|
for ma in mal.into_inner() {
|
||||||
|
match ma {
|
||||||
|
mailparse::MailAddr::Group(gi) => {
|
||||||
|
if !gi.group_name.contains("ndisclosed") {
|
||||||
|
println!("[{path}][{header_name}] Group: {gi}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mailparse::MailAddr::Single(s) => addrs.push(Email {
|
||||||
|
name: s.display_name,
|
||||||
|
addr: Some(s.addr),
|
||||||
|
}), //println!("Single: {s}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let v = header_value;
|
||||||
|
if v.matches('@').count() == 1 {
|
||||||
|
if v.matches('<').count() == 1 && v.ends_with('>') {
|
||||||
|
let idx = v.find('<').unwrap();
|
||||||
|
let addr = &v[idx + 1..v.len() - 1].trim();
|
||||||
|
let name = &v[..idx].trim();
|
||||||
|
addrs.push(Email {
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
addr: Some(addr.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addrs.push(Email {
|
||||||
|
name: Some(v),
|
||||||
|
addr: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(addrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cid_attachment_bytes(nm: &Notmuch, id: &str, cid: &str) -> Result<Attachment, ServerError> {
|
||||||
|
let files = nm.files(id)?;
|
||||||
|
let Some(path) = files.first() else {
|
||||||
|
warn!("failed to find files for message {id}");
|
||||||
|
return Err(ServerError::PartNotFound);
|
||||||
|
};
|
||||||
|
let file = File::open(&path)?;
|
||||||
|
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
||||||
|
let m = parse_mail(&mmap)?;
|
||||||
|
if let Some(attachment) = walk_attachments(&m, |sp, _cur_idx| {
|
||||||
|
info!("{cid} {:?}", get_content_id(&sp.headers));
|
||||||
|
if let Some(h_cid) = get_content_id(&sp.headers) {
|
||||||
|
let h_cid = &h_cid[1..h_cid.len() - 1];
|
||||||
|
if h_cid == cid {
|
||||||
|
let attachment = extract_attachment(&sp, id, &[]).unwrap_or(Attachment {
|
||||||
|
..Attachment::default()
|
||||||
|
});
|
||||||
|
return Some(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}) {
|
||||||
|
return Ok(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ServerError::PartNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attachment_bytes(nm: &Notmuch, id: &str, idx: &[usize]) -> Result<Attachment, ServerError> {
|
||||||
|
let files = nm.files(id)?;
|
||||||
|
let Some(path) = files.first() else {
|
||||||
|
warn!("failed to find files for message {id}");
|
||||||
|
return Err(ServerError::PartNotFound);
|
||||||
|
};
|
||||||
|
let file = File::open(&path)?;
|
||||||
|
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
||||||
|
let m = parse_mail(&mmap)?;
|
||||||
|
if let Some(attachment) = walk_attachments(&m, |sp, cur_idx| {
|
||||||
|
if cur_idx == idx {
|
||||||
|
let attachment = extract_attachment(&sp, id, idx).unwrap_or(Attachment {
|
||||||
|
..Attachment::default()
|
||||||
|
});
|
||||||
|
return Some(attachment);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}) {
|
||||||
|
return Ok(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ServerError::PartNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_body(m: &ParsedMail, id: &str) -> Result<Body, ServerError> {
|
||||||
|
let mut part_addr = Vec::new();
|
||||||
|
part_addr.push(id.to_string());
|
||||||
|
let body = m.get_body()?;
|
||||||
|
let ret = match m.ctype.mimetype.as_str() {
|
||||||
|
TEXT_PLAIN => return Ok(Body::text(body)),
|
||||||
|
TEXT_HTML => return Ok(Body::html(body)),
|
||||||
|
MULTIPART_MIXED => extract_mixed(m, &mut part_addr),
|
||||||
|
MULTIPART_ALTERNATIVE => extract_alternative(m, &mut part_addr),
|
||||||
|
MULTIPART_RELATED => extract_related(m, &mut part_addr),
|
||||||
|
_ => extract_unhandled(m),
|
||||||
|
};
|
||||||
|
if let Err(err) = ret {
|
||||||
|
error!("Failed to extract body: {err:?}");
|
||||||
|
return Ok(extract_unhandled(m)?);
|
||||||
|
}
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_unhandled(m: &ParsedMail) -> Result<Body, ServerError> {
|
||||||
|
let msg = format!(
|
||||||
|
"Unhandled body content type:\n{}\n{}",
|
||||||
|
render_content_type_tree(m),
|
||||||
|
m.get_body()?,
|
||||||
|
);
|
||||||
|
Ok(Body::UnhandledContentType(UnhandledContentType {
|
||||||
|
text: msg,
|
||||||
|
content_tree: render_content_type_tree(m),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// multipart/alternative defines multiple representations of the same message, and clients should
|
||||||
|
// show the fanciest they can display. For this program, the priority is text/html, text/plain,
|
||||||
|
// then give up.
|
||||||
|
fn extract_alternative(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
|
||||||
|
let handled_types = vec![
|
||||||
|
MULTIPART_ALTERNATIVE,
|
||||||
|
MULTIPART_MIXED,
|
||||||
|
MULTIPART_RELATED,
|
||||||
|
TEXT_HTML,
|
||||||
|
TEXT_PLAIN,
|
||||||
|
];
|
||||||
|
for sp in &m.subparts {
|
||||||
|
if sp.ctype.mimetype.as_str() == MULTIPART_ALTERNATIVE {
|
||||||
|
return extract_alternative(sp, part_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sp in &m.subparts {
|
||||||
|
if sp.ctype.mimetype.as_str() == MULTIPART_MIXED {
|
||||||
|
return extract_related(sp, part_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sp in &m.subparts {
|
||||||
|
if sp.ctype.mimetype.as_str() == MULTIPART_RELATED {
|
||||||
|
return extract_related(sp, part_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sp in &m.subparts {
|
||||||
|
if sp.ctype.mimetype.as_str() == TEXT_HTML {
|
||||||
|
let body = sp.get_body()?;
|
||||||
|
return Ok(Body::html(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sp in &m.subparts {
|
||||||
|
if sp.ctype.mimetype.as_str() == TEXT_PLAIN {
|
||||||
|
let body = sp.get_body()?;
|
||||||
|
return Ok(Body::text(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ServerError::StringError(format!(
|
||||||
|
"extract_alternative failed to find suitable subpart, searched: {:?}",
|
||||||
|
handled_types
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// multipart/mixed defines multiple types of context all of which should be presented to the user
|
||||||
|
// 'serially'.
|
||||||
|
fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
|
||||||
|
let handled_types = vec![
|
||||||
|
MULTIPART_ALTERNATIVE,
|
||||||
|
MULTIPART_RELATED,
|
||||||
|
TEXT_HTML,
|
||||||
|
TEXT_PLAIN,
|
||||||
|
IMAGE_JPEG,
|
||||||
|
IMAGE_PNG,
|
||||||
|
];
|
||||||
|
let mut unhandled_types: Vec<_> = m
|
||||||
|
.subparts
|
||||||
|
.iter()
|
||||||
|
.map(|sp| sp.ctype.mimetype.as_str())
|
||||||
|
.filter(|mt| !handled_types.contains(&mt))
|
||||||
|
.collect();
|
||||||
|
unhandled_types.sort();
|
||||||
|
if !unhandled_types.is_empty() {
|
||||||
|
warn!("{MULTIPART_MIXED} contains the following unhandled mimetypes {unhandled_types:?}");
|
||||||
|
}
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for (idx, sp) in m.subparts.iter().enumerate() {
|
||||||
|
part_addr.push(idx.to_string());
|
||||||
|
match sp.ctype.mimetype.as_str() {
|
||||||
|
MULTIPART_RELATED => parts.push(extract_related(sp, part_addr)?),
|
||||||
|
MULTIPART_ALTERNATIVE => parts.push(extract_alternative(sp, part_addr)?),
|
||||||
|
TEXT_PLAIN => parts.push(Body::text(sp.get_body()?)),
|
||||||
|
TEXT_HTML => parts.push(Body::html(sp.get_body()?)),
|
||||||
|
IMAGE_JPEG | IMAGE_PNG => {
|
||||||
|
let pcd = sp.get_content_disposition();
|
||||||
|
let filename = pcd
|
||||||
|
.params
|
||||||
|
.get("filename")
|
||||||
|
.map(|s| s.clone())
|
||||||
|
.unwrap_or("".to_string());
|
||||||
|
// Only add inline images, attachments are handled as an attribute of the top level Message and rendered separate client-side.
|
||||||
|
if pcd.disposition == mailparse::DispositionType::Inline {
|
||||||
|
parts.push(Body::html(format!(
|
||||||
|
r#"<img src="/view/attachment/{}/{}/{filename}">"#,
|
||||||
|
part_addr[0],
|
||||||
|
part_addr
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.map(|i| i.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(".")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
part_addr.pop();
|
||||||
|
}
|
||||||
|
Ok(flatten_body_parts(&parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_body_parts(parts: &[Body]) -> Body {
|
||||||
|
let html = parts
|
||||||
|
.iter()
|
||||||
|
.map(|p| match p {
|
||||||
|
Body::PlainText(PlainText { text, .. }) => {
|
||||||
|
format!(
|
||||||
|
r#"<p class="view-part-text-plain">{}</p>"#,
|
||||||
|
// Trim newlines to prevent excessive white space at the beginning/end of
|
||||||
|
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
||||||
|
// header on the first line.
|
||||||
|
linkify_html(&text.trim_matches('\n'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Body::Html(Html { html, .. }) => html.clone(),
|
||||||
|
Body::UnhandledContentType(UnhandledContentType { text, .. }) => {
|
||||||
|
error!("text len {}", text.len());
|
||||||
|
format!(
|
||||||
|
r#"<p class="view-part-unhandled">{}</p>"#,
|
||||||
|
// Trim newlines to prevent excessive white space at the beginning/end of
|
||||||
|
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
||||||
|
// header on the first line.
|
||||||
|
linkify_html(&text.trim_matches('\n'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
info!("flatten_body_parts {} {html}", parts.len());
|
||||||
|
Body::html(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_related(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
|
||||||
|
// TODO(wathiede): collect related things and change return type to new Body arm.
|
||||||
|
let handled_types = vec![
|
||||||
|
MULTIPART_ALTERNATIVE,
|
||||||
|
TEXT_HTML,
|
||||||
|
TEXT_PLAIN,
|
||||||
|
IMAGE_JPEG,
|
||||||
|
IMAGE_PNG,
|
||||||
|
];
|
||||||
|
let mut unhandled_types: Vec<_> = m
|
||||||
|
.subparts
|
||||||
|
.iter()
|
||||||
|
.map(|sp| sp.ctype.mimetype.as_str())
|
||||||
|
.filter(|mt| !handled_types.contains(&mt))
|
||||||
|
.collect();
|
||||||
|
unhandled_types.sort();
|
||||||
|
if !unhandled_types.is_empty() {
|
||||||
|
warn!("{MULTIPART_RELATED} contains the following unhandled mimetypes {unhandled_types:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, sp) in m.subparts.iter().enumerate() {
|
||||||
|
if sp.ctype.mimetype == IMAGE_PNG || sp.ctype.mimetype == IMAGE_JPEG {
|
||||||
|
info!("sp.ctype {:#?}", sp.ctype);
|
||||||
|
//info!("sp.headers {:#?}", sp.headers);
|
||||||
|
if let Some(cid) = sp.headers.get_first_value("Content-Id") {
|
||||||
|
let mut part_id = part_addr.clone();
|
||||||
|
part_id.push(i.to_string());
|
||||||
|
info!("cid: {cid} part_id {part_id:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sp in &m.subparts {
|
||||||
|
if sp.ctype.mimetype == MULTIPART_ALTERNATIVE {
|
||||||
|
return extract_alternative(m, part_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sp in &m.subparts {
|
||||||
|
if sp.ctype.mimetype == TEXT_HTML {
|
||||||
|
let body = sp.get_body()?;
|
||||||
|
return Ok(Body::html(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sp in &m.subparts {
|
||||||
|
if sp.ctype.mimetype == TEXT_PLAIN {
|
||||||
|
let body = sp.get_body()?;
|
||||||
|
return Ok(Body::text(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(ServerError::StringError(format!(
|
||||||
|
"extract_related failed to find suitable subpart, searched: {:?}",
|
||||||
|
handled_types
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_attachments<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
|
||||||
|
m: &ParsedMail,
|
||||||
|
visitor: F,
|
||||||
|
) -> Option<T> {
|
||||||
|
let mut cur_addr = Vec::new();
|
||||||
|
walk_attachments_inner(m, visitor, &mut cur_addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_attachments_inner<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
|
||||||
|
m: &ParsedMail,
|
||||||
|
visitor: F,
|
||||||
|
cur_addr: &mut Vec<usize>,
|
||||||
|
) -> Option<T> {
|
||||||
|
for (idx, sp) in m.subparts.iter().enumerate() {
|
||||||
|
cur_addr.push(idx);
|
||||||
|
let val = visitor(sp, &cur_addr);
|
||||||
|
if val.is_some() {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
let val = walk_attachments_inner(sp, visitor, cur_addr);
|
||||||
|
if val.is_some() {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
cur_addr.pop();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(wathiede): make this walk_attachments that takes a closure.
|
||||||
|
// Then implement one closure for building `Attachment` and imlement another that can be used to
|
||||||
|
// get the bytes for serving attachments of HTTP
|
||||||
|
fn extract_attachments(m: &ParsedMail, id: &str) -> Result<Vec<Attachment>, ServerError> {
|
||||||
|
let mut attachments = Vec::new();
|
||||||
|
for (idx, sp) in m.subparts.iter().enumerate() {
|
||||||
|
if let Some(attachment) = extract_attachment(sp, id, &[idx]) {
|
||||||
|
// Filter out inline attachements, they're flattened into the body of the message.
|
||||||
|
if attachment.disposition == DispositionType::Attachment {
|
||||||
|
attachments.push(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_attachment(m: &ParsedMail, id: &str, idx: &[usize]) -> Option<Attachment> {
|
||||||
|
let pcd = m.get_content_disposition();
|
||||||
|
// TODO: do we need to handle empty filename attachments, or should we change the definition of
|
||||||
|
// Attachment::filename?
|
||||||
|
let Some(filename) = pcd.params.get("filename").map(|f| f.clone()) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: grab this from somewhere
|
||||||
|
let content_id = None;
|
||||||
|
let bytes = match m.get_body_raw() {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to get body for attachment: {err}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Some(Attachment {
|
||||||
|
id: id.to_string(),
|
||||||
|
idx: idx
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("."),
|
||||||
|
disposition: pcd.disposition.into(),
|
||||||
|
filename: Some(filename),
|
||||||
|
size: bytes.len(),
|
||||||
|
// TODO: what is the default for ctype?
|
||||||
|
// TODO: do we want to use m.ctype.params for anything?
|
||||||
|
content_type: Some(m.ctype.mimetype.clone()),
|
||||||
|
content_id,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_attachment_filename(header_value: &str) -> &str {
|
||||||
|
info!("get_attachment_filename {header_value}");
|
||||||
|
// Strip last "
|
||||||
|
let v = &header_value[..header_value.len() - 1];
|
||||||
|
if let Some(idx) = v.rfind('"') {
|
||||||
|
&v[idx + 1..]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_content_type<'a>(headers: &[MailHeader<'a>]) -> Option<String> {
|
||||||
|
if let Some(v) = headers.get_first_value("Content-Type") {
|
||||||
|
if let Some(idx) = v.find(';') {
|
||||||
|
return Some(v[..idx].to_string());
|
||||||
|
} else {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_content_id<'a>(headers: &[MailHeader<'a>]) -> Option<String> {
|
||||||
|
headers.get_first_value("Content-Id")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_content_type_tree(m: &ParsedMail) -> String {
|
||||||
|
const WIDTH: usize = 4;
|
||||||
|
const SKIP_HEADERS: [&str; 4] = [
|
||||||
|
"Authentication-Results",
|
||||||
|
"DKIM-Signature",
|
||||||
|
"Received",
|
||||||
|
"Received-SPF",
|
||||||
|
];
|
||||||
|
fn render_ct_rec(m: &ParsedMail, depth: usize) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
|
||||||
|
parts.push(msg);
|
||||||
|
for sp in &m.subparts {
|
||||||
|
parts.push(render_ct_rec(sp, depth + 1))
|
||||||
|
}
|
||||||
|
parts.join("\n")
|
||||||
|
}
|
||||||
|
fn render_rec(m: &ParsedMail, depth: usize) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
|
||||||
|
parts.push(msg);
|
||||||
|
let indent = " ".repeat(depth * WIDTH);
|
||||||
|
if !m.ctype.charset.is_empty() {
|
||||||
|
parts.push(format!("{indent} Character Set: {}", m.ctype.charset));
|
||||||
|
}
|
||||||
|
for (k, v) in m.ctype.params.iter() {
|
||||||
|
parts.push(format!("{indent} {k}: {v}"));
|
||||||
|
}
|
||||||
|
if !m.headers.is_empty() {
|
||||||
|
parts.push(format!("{indent} == headers =="));
|
||||||
|
for h in &m.headers {
|
||||||
|
if h.get_key().starts_with('X') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if SKIP_HEADERS.contains(&h.get_key().as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(format!("{indent} {}: {}", h.get_key_ref(), h.get_value()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sp in &m.subparts {
|
||||||
|
parts.push(render_rec(sp, depth + 1))
|
||||||
|
}
|
||||||
|
parts.join("\n")
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"Outline:\n{}\n\nDetailed:\n{}\n\nNot showing headers:\n {}\n X.*",
|
||||||
|
render_ct_rec(m, 1),
|
||||||
|
render_rec(m, 1),
|
||||||
|
SKIP_HEADERS.join("\n ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -238,6 +238,14 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
first,
|
first,
|
||||||
last,
|
last,
|
||||||
} => {
|
} => {
|
||||||
|
let (after, before, first, last) = match (after.as_ref(), before.as_ref(), first, last)
|
||||||
|
{
|
||||||
|
// If no pagination set, set reasonable defaults
|
||||||
|
(None, None, None, None) => {
|
||||||
|
(None, None, Some(SEARCH_RESULTS_PER_PAGE as i64), None)
|
||||||
|
}
|
||||||
|
_ => (after, before, first, last),
|
||||||
|
};
|
||||||
model.query = query.clone();
|
model.query = query.clone();
|
||||||
orders.skip().perform_cmd(async move {
|
orders.skip().perform_cmd(async move {
|
||||||
Msg::FrontPageResult(
|
Msg::FrontPageResult(
|
||||||
|
|||||||
@ -12,7 +12,6 @@ use seed_hooks::{state_access::CloneState, topo, use_state};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::urls,
|
api::urls,
|
||||||
consts::SEARCH_RESULTS_PER_PAGE,
|
|
||||||
graphql::{front_page_query::*, show_thread_query::*},
|
graphql::{front_page_query::*, show_thread_query::*},
|
||||||
state::{unread_query, Model, Msg, RefreshingState},
|
state::{unread_query, Model, Msg, RefreshingState},
|
||||||
};
|
};
|
||||||
@ -308,7 +307,14 @@ fn search_toolbar(
|
|||||||
.start_cursor
|
.start_cursor
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|i| i.parse().unwrap_or(0))
|
.map(|i| i.parse().unwrap_or(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0)
|
||||||
|
+ 1;
|
||||||
|
let end = pager
|
||||||
|
.end_cursor
|
||||||
|
.as_ref()
|
||||||
|
.map(|i| i.parse().unwrap_or(count))
|
||||||
|
.unwrap_or(count)
|
||||||
|
+ 1;
|
||||||
nav![
|
nav![
|
||||||
C!["level", "is-mobile"],
|
C!["level", "is-mobile"],
|
||||||
IF!(show_bulk_edit =>
|
IF!(show_bulk_edit =>
|
||||||
@ -377,12 +383,7 @@ fn search_toolbar(
|
|||||||
],
|
],
|
||||||
ul![
|
ul![
|
||||||
C!["pagination-list"],
|
C!["pagination-list"],
|
||||||
li![format!(
|
li![format!("{} - {} of {}", start, end, count)],
|
||||||
"{} - {} of {}",
|
|
||||||
start,
|
|
||||||
count.min(start + SEARCH_RESULTS_PER_PAGE),
|
|
||||||
count
|
|
||||||
)],
|
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user