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"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.2.15",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
@ -59,6 +60,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "3.3.0"
|
||||
@ -241,6 +248,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "atomic"
|
||||
version = "0.5.3"
|
||||
@ -312,6 +328,12 @@ version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||
|
||||
[[package]]
|
||||
name = "bimap"
|
||||
version = "0.6.3"
|
||||
@ -335,6 +357,9 @@ name = "bitflags"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@ -441,6 +466,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-sha1"
|
||||
version = "0.2.0"
|
||||
@ -489,6 +520,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.5"
|
||||
@ -508,6 +554,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.20"
|
||||
@ -658,6 +713,17 @@ version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "deranged"
|
||||
version = "0.3.11"
|
||||
@ -726,9 +792,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
@ -769,6 +843,9 @@ name = "either"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enclose"
|
||||
@ -801,6 +878,23 @@ dependencies = [
|
||||
"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]]
|
||||
name = "fast_chemail"
|
||||
version = "0.9.6"
|
||||
@ -830,6 +924,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@ -897,6 +1002,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "futures-io"
|
||||
version = "0.3.30"
|
||||
@ -1249,12 +1365,28 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
@ -1262,6 +1394,39 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "html5ever"
|
||||
version = "0.26.0"
|
||||
@ -1523,6 +1688,9 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazycell"
|
||||
@ -1559,6 +1727,23 @@ version = "0.2.155"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "linkify"
|
||||
version = "0.10.0"
|
||||
@ -1676,6 +1861,16 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "memchr"
|
||||
version = "2.7.4"
|
||||
@ -1707,6 +1902,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.7.4"
|
||||
@ -1789,6 +1990,16 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "notmuch"
|
||||
version = "0.1.0"
|
||||
@ -1802,12 +2013,49 @@ dependencies = [
|
||||
"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]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@ -1815,6 +2063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1928,6 +2177,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.1"
|
||||
@ -2151,6 +2409,33 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
@ -2610,6 +2895,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
@ -2875,8 +3180,10 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
@ -2899,6 +3206,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.8"
|
||||
@ -2936,6 +3254,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@ -2981,6 +3309,227 @@ name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "stable-pattern"
|
||||
@ -3038,6 +3587,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "strsim"
|
||||
version = "0.9.3"
|
||||
@ -3072,6 +3632,12 @@ dependencies = [
|
||||
"syn 2.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@ -3378,6 +3944,7 @@ version = "0.1.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@ -3510,12 +4077,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode_categories"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "unreachable"
|
||||
version = "1.0.0"
|
||||
@ -3569,6 +4154,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.4"
|
||||
@ -4003,3 +4594,9 @@ dependencies = [
|
||||
"quote",
|
||||
"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"
|
||||
maplit = "1.0.2"
|
||||
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
|
||||
# Uncomment to make it production like.
|
||||
#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 notmuch::{Notmuch, NotmuchError, ThreadSet};
|
||||
use rocket::{
|
||||
fairing::AdHoc,
|
||||
http::{ContentType, Header},
|
||||
request::Request,
|
||||
response::{content, Debug, Responder},
|
||||
@ -14,46 +15,24 @@ use rocket::{
|
||||
Response, State,
|
||||
};
|
||||
use rocket_cors::{AllowedHeaders, AllowedOrigins};
|
||||
use serde::Deserialize;
|
||||
use server::{
|
||||
error::ServerError,
|
||||
graphql::{
|
||||
attachment_bytes, cid_attachment_bytes, Attachment, GraphqlSchema, Mutation, QueryRoot,
|
||||
},
|
||||
graphql::{Attachment, GraphqlSchema, Mutation, QueryRoot},
|
||||
nm::{attachment_bytes, cid_attachment_bytes},
|
||||
};
|
||||
use sqlx::postgres::PgPool;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Config {
|
||||
newsreader_database_url: String,
|
||||
}
|
||||
|
||||
#[get("/refresh")]
|
||||
async fn refresh(nm: &State<Notmuch>) -> Result<Json<String>, Debug<NotmuchError>> {
|
||||
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")]
|
||||
async fn show_pretty(
|
||||
nm: &State<Notmuch>,
|
||||
@ -224,19 +203,12 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
.to_cors()?;
|
||||
|
||||
let schema = Schema::build(QueryRoot, Mutation, EmptySubscription)
|
||||
.data(Notmuch::default())
|
||||
.extension(async_graphql::extensions::Logger)
|
||||
.finish();
|
||||
|
||||
let _ = rocket::build()
|
||||
let rkt = rocket::build()
|
||||
.mount(
|
||||
shared::urls::MOUNT_POINT,
|
||||
routes![
|
||||
original,
|
||||
refresh,
|
||||
search_all,
|
||||
search,
|
||||
show_pretty,
|
||||
show,
|
||||
graphql_query,
|
||||
@ -248,11 +220,19 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
],
|
||||
)
|
||||
.attach(cors)
|
||||
.manage(schema)
|
||||
.manage(Notmuch::default())
|
||||
//.manage(Notmuch::with_config("../notmuch/testdata/notmuch.config"))
|
||||
.launch()
|
||||
.await?;
|
||||
.attach(AdHoc::config::<Config>());
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@ -1,16 +1,34 @@
|
||||
use std::{convert::Infallible, str::Utf8Error, string::FromUtf8Error};
|
||||
|
||||
use mailparse::MailParseError;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::SanitizeError;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ServerError {
|
||||
#[error("notmuch")]
|
||||
#[error("notmuch: {0}")]
|
||||
NotmuchError(#[from] notmuch::NotmuchError),
|
||||
#[error("flatten")]
|
||||
FlattenError,
|
||||
#[error("mail parse error")]
|
||||
#[error("mail parse error: {0}")]
|
||||
MailParseError(#[from] MailParseError),
|
||||
#[error("IO error")]
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
#[error("attachement not found")]
|
||||
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::{
|
||||
connection::{self, Connection, Edge},
|
||||
connection::{Connection},
|
||||
Context, EmptySubscription, Enum, Error, FieldResult, Object, Schema, SimpleObject, Union,
|
||||
};
|
||||
use log::{error, info, warn};
|
||||
use mailparse::{parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
||||
use memmap::MmapOptions;
|
||||
use log::info;
|
||||
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
|
||||
pub type UnixTime = isize;
|
||||
@ -23,14 +15,6 @@ pub type UnixTime = isize;
|
||||
/// # Thread ID, sans "thread:"
|
||||
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)]
|
||||
pub struct ThreadSummary {
|
||||
pub thread: ThreadId,
|
||||
@ -49,9 +33,9 @@ pub struct ThreadSummary {
|
||||
|
||||
#[derive(Debug, SimpleObject)]
|
||||
pub struct Thread {
|
||||
thread_id: String,
|
||||
subject: String,
|
||||
messages: Vec<Message>,
|
||||
pub thread_id: String,
|
||||
pub subject: String,
|
||||
pub messages: Vec<Message>,
|
||||
}
|
||||
|
||||
#[derive(Debug, SimpleObject)]
|
||||
@ -95,16 +79,45 @@ pub struct Attachment {
|
||||
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)]
|
||||
pub struct Header {
|
||||
key: String,
|
||||
value: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UnhandledContentType {
|
||||
text: String,
|
||||
content_tree: String,
|
||||
pub text: String,
|
||||
pub content_tree: String,
|
||||
}
|
||||
|
||||
#[Object]
|
||||
@ -119,8 +132,8 @@ impl UnhandledContentType {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlainText {
|
||||
text: String,
|
||||
content_tree: String,
|
||||
pub text: String,
|
||||
pub content_tree: String,
|
||||
}
|
||||
|
||||
#[Object]
|
||||
@ -135,8 +148,8 @@ impl PlainText {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Html {
|
||||
html: String,
|
||||
content_tree: String,
|
||||
pub html: String,
|
||||
pub content_tree: String,
|
||||
}
|
||||
|
||||
#[Object]
|
||||
@ -160,13 +173,13 @@ pub enum Body {
|
||||
}
|
||||
|
||||
impl Body {
|
||||
fn html(html: String) -> Body {
|
||||
pub fn html(html: String) -> Body {
|
||||
Body::Html(Html {
|
||||
html,
|
||||
content_tree: "".to_string(),
|
||||
})
|
||||
}
|
||||
fn text(text: String) -> Body {
|
||||
pub fn text(text: String) -> Body {
|
||||
Body::PlainText(PlainText {
|
||||
text,
|
||||
content_tree: "".to_string(),
|
||||
@ -181,11 +194,11 @@ pub struct Email {
|
||||
}
|
||||
|
||||
#[derive(SimpleObject)]
|
||||
struct Tag {
|
||||
name: String,
|
||||
fg_color: String,
|
||||
bg_color: String,
|
||||
unread: usize,
|
||||
pub(crate) struct Tag {
|
||||
pub name: String,
|
||||
pub fg_color: String,
|
||||
pub bg_color: String,
|
||||
pub unread: usize,
|
||||
}
|
||||
|
||||
pub struct QueryRoot;
|
||||
@ -193,7 +206,14 @@ pub struct QueryRoot;
|
||||
impl QueryRoot {
|
||||
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
|
||||
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>(
|
||||
@ -205,230 +225,41 @@ impl QueryRoot {
|
||||
last: Option<i32>,
|
||||
query: String,
|
||||
) -> Result<Connection<usize, ThreadSummary>, Error> {
|
||||
info!("search({after:?} {before:?} {first:?} {last:?} {query:?})");
|
||||
let nm = ctx.data_unchecked::<Notmuch>();
|
||||
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)
|
||||
let pool = ctx.data_unchecked::<PgPool>();
|
||||
|
||||
// TODO: make this search both copra and merge results
|
||||
if newsreader::is_newsreader_search(&query) {
|
||||
Ok(newsreader::search(pool, after, before, first, last, query).await?)
|
||||
} 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);
|
||||
Ok(nm::search(nm, after, before, first, last, query).await?)
|
||||
}
|
||||
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>> {
|
||||
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 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());
|
||||
let mut tags = newsreader::tags(pool, needs_unread).await?;
|
||||
tags.append(&mut nm::tags(nm, needs_unread)?);
|
||||
Ok(tags)
|
||||
}
|
||||
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 pool = ctx.data_unchecked::<PgPool>();
|
||||
let debug_content_tree = ctx
|
||||
.look_ahead()
|
||||
.field("messages")
|
||||
.field("body")
|
||||
.field("contentTree")
|
||||
.exists();
|
||||
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 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
|
||||
)
|
||||
// TODO: look at thread_id and conditionally load newsreader
|
||||
if newsreader::is_newsreader_thread(&thread_id) {
|
||||
Ok(newsreader::thread(pool, thread_id).await?)
|
||||
} 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)?
|
||||
),
|
||||
content_tree: if debug_content_tree {
|
||||
render_content_type_tree(&m)
|
||||
} else {
|
||||
content_tree
|
||||
},
|
||||
})
|
||||
Ok(nm::thread(nm, thread_id, debug_content_tree).await?)
|
||||
}
|
||||
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>;
|
||||
|
||||
#[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 graphql;
|
||||
pub mod newsreader;
|
||||
pub mod nm;
|
||||
|
||||
use css_inline::{CSSInliner, InlineError, InlineOptions};
|
||||
@ -8,6 +9,7 @@ use log::error;
|
||||
use lol_html::{element, errors::RewritingError, rewrite_str, RewriteStrSettings};
|
||||
use maplit::{hashmap, hashset};
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
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
|
||||
// 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![
|
||||
// Open links in new tab
|
||||
element!("a[href]", |el| {
|
||||
@ -53,6 +59,22 @@ pub fn sanitize_html(html: &str, cid_prefix: &str) -> Result<String, SanitizeErr
|
||||
|
||||
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
|
||||
element!("img[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
|
||||
pub fn threadset_to_messages(
|
||||
thread_set: notmuch::ThreadSet,
|
||||
) -> Result<Vec<Message>, error::ServerError> {
|
||||
pub fn threadset_to_messages(thread_set: notmuch::ThreadSet) -> Result<Vec<Message>, ServerError> {
|
||||
for t in thread_set.0 {
|
||||
for _tn in t.0 {}
|
||||
}
|
||||
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,
|
||||
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();
|
||||
orders.skip().perform_cmd(async move {
|
||||
Msg::FrontPageResult(
|
||||
|
||||
@ -12,7 +12,6 @@ use seed_hooks::{state_access::CloneState, topo, use_state};
|
||||
|
||||
use crate::{
|
||||
api::urls,
|
||||
consts::SEARCH_RESULTS_PER_PAGE,
|
||||
graphql::{front_page_query::*, show_thread_query::*},
|
||||
state::{unread_query, Model, Msg, RefreshingState},
|
||||
};
|
||||
@ -308,7 +307,14 @@ fn search_toolbar(
|
||||
.start_cursor
|
||||
.as_ref()
|
||||
.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![
|
||||
C!["level", "is-mobile"],
|
||||
IF!(show_bulk_edit =>
|
||||
@ -377,12 +383,7 @@ fn search_toolbar(
|
||||
],
|
||||
ul![
|
||||
C!["pagination-list"],
|
||||
li![format!(
|
||||
"{} - {} of {}",
|
||||
start,
|
||||
count.min(start + SEARCH_RESULTS_PER_PAGE),
|
||||
count
|
||||
)],
|
||||
li![format!("{} - {} of {}", start, end, count)],
|
||||
],
|
||||
]
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user