Compare commits
412 Commits
tailwind
...
letterbox-
| Author | SHA1 | Date | |
|---|---|---|---|
| dfa80f9046 | |||
| b8dfdabf8d | |||
| bbcf52b006 | |||
| f92c05cd28 | |||
| 885bbe0a8c | |||
| 8b1d111837 | |||
| 08abf31fa9 | |||
| fa99959508 | |||
| 0f6af0f475 | |||
| 4c486e9168 | |||
| 109d380ea7 | |||
| 4244fa0d82 | |||
| 4b15e71893 | |||
| 1bbebad01b | |||
| 27edffd090 | |||
| 08212a9f78 | |||
| 877ec6c4b0 | |||
| 3ce92d6bdf | |||
| 1a28bb2021 | |||
| b86f72f75c | |||
| 1a8b98d420 | |||
| 383a7d800f | |||
| 453561140a | |||
| f6d5d3755b | |||
| 5226fe090e | |||
| c10ad00ca7 | |||
| 64fc92c3d6 | |||
| b9c116d5b6 | |||
| 007200b37b | |||
| 9824ad1e18 | |||
| a8819c7551 | |||
| 8cdfbdd08f | |||
| b2d1dc9276 | |||
| 1f79b43a85 | |||
| 904619bccd | |||
| 14104f6469 | |||
| dccfb6f71f | |||
| 547266a705 | |||
| 273562b58c | |||
| dc39eed1a7 | |||
| 9178badfd0 | |||
| 38e75ec251 | |||
| c1496bf87b | |||
| 4da888b240 | |||
| c703be2ca5 | |||
| 5cec8add5e | |||
| 0225dbde3a | |||
| f84b8fa6c2 | |||
| 979cbcd23e | |||
| b3070e1919 | |||
| e5fdde8f30 | |||
| 7de36bbc3d | |||
| 1c4f27902e | |||
| 7ee86f0d2f | |||
| a0b06fd5ef | |||
| 630bb20b35 | |||
| 17ea2a35cb | |||
| 7d9376d607 | |||
| 122e949072 | |||
| 9a69b4c51e | |||
| 251151244b | |||
| 9d232b666b | |||
| 1832d77e78 | |||
| aca6bce1ff | |||
| 7bb2f405da | |||
| 60e2824167 | |||
| cffc228b3a | |||
| 318c366d82 | |||
| 90d7f79ca0 | |||
| 3f87038776 | |||
| 92b880f03b | |||
| 94f1e84857 | |||
| 221b4f10df | |||
| 225615f4ea | |||
| b8ef753f85 | |||
| 33edd22f8f | |||
| 75e9232095 | |||
| 6daddf11de | |||
| 36d9eda303 | |||
| 4eb2d4c689 | |||
| edc7119fbf | |||
| aa1736a285 | |||
| 6f93aa4f34 | |||
| 0662e6230e | |||
| 30f3f14040 | |||
| f2042f284e | |||
| b2c73ffa15 | |||
| d7217d1b3c | |||
| 638d55a36c | |||
| b11f6b5149 | |||
| d0b5ecf4f2 | |||
| 7a67c30a2c | |||
| 5ea4694eb8 | |||
| e01dabe6ed | |||
| ecaf0dd0fc | |||
| 3d4dcc9e6b | |||
| 28a5d9f219 | |||
| 81876d37ea | |||
| 4a6b159ddb | |||
| d84957cc8c | |||
| d53db5b49a | |||
| 0448368011 | |||
| 36754136fd | |||
| 489acccf77 | |||
| 8ef4db63ad | |||
| 9f63205ff3 | |||
| 5a0378948d | |||
| 2b4c45be74 | |||
| 147896dc80 | |||
| 1ff6ec7653 | |||
| acd590111e | |||
| b5f24ba1f2 | |||
| 79ed24135f | |||
| a4949a25b5 | |||
| f16edef124 | |||
| 2fd6479cb9 | |||
| 85a6b3a9a4 | |||
| 9ac5216d6e | |||
| 82987dbd20 | |||
| 29de7c0727 | |||
| 5f6580fa2f | |||
| 5d4732d75d | |||
| a13bac813a | |||
| 85dcc9f7bd | |||
| b696629ad9 | |||
| b9e3128718 | |||
| 88fac4c2bc | |||
| 1fad5ec536 | |||
| 8e7214d531 | |||
| 333c4a3ebb | |||
| b9ba5a3bea | |||
| 2a0989e74d | |||
| e9319dc491 | |||
| 57481a77cd | |||
| 44915cce54 | |||
| 1225483b57 | |||
| daeb8c88a1 | |||
| 8a6b3ff501 | |||
| a6fffeafdc | |||
| d791b4ce49 | |||
| 8a0e4eb441 | |||
| fc84562419 | |||
| 37ebe1ebb3 | |||
| 2d06f070ea | |||
| 527a62069a | |||
| 40afafe1a8 | |||
| e3acf9ae6d | |||
| a68d067a68 | |||
| 5547c65af0 | |||
| b622bb7d7d | |||
| 43efdf18a0 | |||
| c71ab8e9e8 | |||
| 408d6ed8ba | |||
| 1411961e36 | |||
| dfd7ef466c | |||
| 2aa3dfbd0f | |||
| fba10e27cf | |||
| 5417c74f9c | |||
| eb0b0dbe81 | |||
| 561f522658 | |||
| 32d2ffeb3d | |||
| d41946e0a5 | |||
| 61402858f4 | |||
| 17de318645 | |||
| 3aa0144e8d | |||
| f9eafff4c7 | |||
| 4c6d67901d | |||
| e9aa97a089 | |||
| a82b047f75 | |||
| 9a8b44a8df | |||
| a96693004c | |||
| ed9fe11fbf | |||
| 09fb14a796 | |||
| 58a7936bba | |||
| cd0ee361f5 | |||
| 77bd5abe0d | |||
| 450c5496b3 | |||
| 4411e45a3c | |||
| e7d20896d5 | |||
| 32a1115abd | |||
| 4982057500 | |||
| 8977f8bab5 | |||
| 0962a6b3cf | |||
| 3c72929a4f | |||
| e4eb495a70 | |||
| 00e8b0342e | |||
| b1f9867c06 | |||
| 77943b3570 | |||
| 45e4edb1dd | |||
| 9bf53afebf | |||
| e1a502ac4b | |||
| 9346c46e62 | |||
| 1452746305 | |||
| 2e526dace1 | |||
| 76be5b7cac | |||
| 3f0b2caedf | |||
| ec6dc35ca8 | |||
| 01e1ca927e | |||
| 1cc52d6c96 | |||
| e6b3a5b5a9 | |||
| bc4b15a5aa | |||
| 00f61cf6be | |||
| 52e24437bd | |||
| 393ffc8506 | |||
| 2b6cb6ec6e | |||
| 0cba3a624c | |||
| 73433711ca | |||
| 965afa6871 | |||
| e70dbaf917 | |||
| 6b4ce11743 | |||
| d1980a55a7 | |||
| 8b78b39d4c | |||
| ae17651eb5 | |||
| 22fd8409f6 | |||
| d0a4ba417f | |||
| 7b09b098a4 | |||
| bd4c10a8fb | |||
| ed3c5f152e | |||
| 63232d1e92 | |||
| 4a3eba80d5 | |||
| 71d3745342 | |||
| 5fdc98633d | |||
| 57877f268d | |||
| 871a93d58f | |||
| 4b7cbd4f9b | |||
| aa2a9815df | |||
| 2e5b18a008 | |||
| d0a38114cc | |||
| ccc1d516c7 | |||
| 246b710fdd | |||
| 1a21c9fa8e | |||
| 9fd912b1d4 | |||
| 9ded32f97b | |||
| 10aac046bc | |||
| f4527baf89 | |||
| 11ec5bf747 | |||
| 6a53679755 | |||
| 7bedec0692 | |||
| 78feb95811 | |||
| 3aad2bb80e | |||
| 0df8de3661 | |||
| 83ecc73fbd | |||
| c10313cd12 | |||
| 4c98bcd9cb | |||
| 004de235a8 | |||
| 90dbeb6f20 | |||
| 9aa298febe | |||
| 5a13a497dc | |||
| 37711e14dd | |||
| e89fd28707 | |||
| 7a91ee2f49 | |||
| 4b76ea5392 | |||
| d2a81b7bd9 | |||
| 9dd39509b5 | |||
| d605bcfe7a | |||
| 73abdb535a | |||
| ab9506c4f6 | |||
| 994a629401 | |||
| 00c55160a7 | |||
| e3c6edb894 | |||
| 4574c016cd | |||
| ca6c19f4c8 | |||
| 0f51f6e71f | |||
| 4bd672bf94 | |||
| 136fd77f3b | |||
| ee9b6be95e | |||
| 38c553d385 | |||
| 1b073665a7 | |||
| 2076596f50 | |||
| d1beaded09 | |||
| 2562bdfedf | |||
| 86c6face7d | |||
| 4a7ff8bf7b | |||
| 8c280d3616 | |||
| eb4d4164ef | |||
| c7740811bf | |||
| 55679cf61b | |||
| 1b1c80b1b8 | |||
| 8743b1f56b | |||
| eb6f1b5346 | |||
| 6bb6d380a9 | |||
| 39eea04bf6 | |||
| 2711147cd6 | |||
| 083b7c9f1c | |||
| 5ade886a72 | |||
| 52575e13f6 | |||
| 3aaee8add3 | |||
| 5e188a70f9 | |||
| f9e5c87d2b | |||
| 7d40cf8a4a | |||
| 1836026736 | |||
| 79db0f8cfa | |||
| 95c29dc73c | |||
| 2b0ee42cdc | |||
| c90ac1d4fc | |||
| a9803bb6a1 | |||
| 74219ad333 | |||
| 2073b7b132 | |||
| 58dae5df6f | |||
| c89fc9b6d4 | |||
| f7ab08c1e6 | |||
| 221fead7dc | |||
| 3491cb9593 | |||
| 037b3231ac | |||
| 75f38c1e94 | |||
| 977bcd0bf4 | |||
| 838459e5a8 | |||
| d208a31348 | |||
| 0a640bea6f | |||
| 84a2962561 | |||
| 6c71be7a3a | |||
| 77562505b4 | |||
| c83d3dcf1d | |||
| 081077d2c2 | |||
| 4cfc6a73fc | |||
| f1c132830f | |||
| 5aff7c6e85 | |||
| 2c09713e20 | |||
| 3d544feeb5 | |||
| 5830ed0bb1 | |||
| 83aed683f5 | |||
| 72385b3987 | |||
| f21893b52e | |||
| 0b81529509 | |||
| 9790bbea83 | |||
| 7aa620a9da | |||
| 2e67db0b4e | |||
| cd777b2894 | |||
| 049e9728a2 | |||
| 0952cdf9cb | |||
| 5f4a4e81cb | |||
| 38c2c508e8 | |||
| 4cd3664e32 | |||
| 71996f6c48 | |||
| 6e227de00f | |||
| 3576e67af7 | |||
| 19f0f60653 | |||
| 3502eeb711 | |||
| fd770d03ab | |||
| d99b7ae34c | |||
| f18aa8c8d4 | |||
| dcdcb5b5a3 | |||
| 884e4b5831 | |||
| 5981356492 | |||
| 386b6915c5 | |||
| 5a6f04536f | |||
| ae1d9e6db7 | |||
| 24d50c21f5 | |||
| b4d72da639 | |||
| dacb258289 | |||
| 5c674d4603 | |||
| 2e9753e91d | |||
| 971e1049c7 | |||
| 11c76332f3 | |||
| 52d03ae964 | |||
| c4043f6c56 | |||
| dfbac38281 | |||
| f857c38625 | |||
| 23823cd85e | |||
| 30b5d0ff9f | |||
| 60a3b1ef88 | |||
| a46390d110 | |||
| 5baac0c77a | |||
| e6181d41ed | |||
| 6a228cfd5e | |||
| 8d81067206 | |||
| b2e47a9bd4 | |||
| 4eaf50cde4 | |||
| f20afe5447 | |||
| 53093f4cce | |||
| 9324a34d31 | |||
| eecc4bc3ef | |||
| 795029cb06 | |||
| bc0135106f | |||
| bd2803f81c | |||
| 215addc2c0 | |||
| 69f8e24689 | |||
| 0817a7a51b | |||
| 200933591a | |||
| 8b7c819b17 | |||
| dce433ab5a | |||
| eb4f2d8b5d | |||
| 2008457911 | |||
| f6b57e63fd | |||
| d681612e8e | |||
| 80454cbc7e | |||
| 78cf59333e | |||
| ab47f32b52 | |||
| d9d58afed9 | |||
| d01f9a7e08 | |||
| c6aabf88b9 | |||
| 29bf6d9b6d | |||
| 92bf45bd15 | |||
| 12c8e0e33b | |||
| c7aa32b922 | |||
| 94be4ec572 | |||
| 66c299bc4c | |||
| d5c4176392 | |||
| bd00542c28 | |||
| 19f029cb6b | |||
| 198db1492a | |||
| f6665b6b6e | |||
| ee93d725ba | |||
| 70fb635eda | |||
| b9fbefe05c | |||
| 46f823baae | |||
| cc1e998ec5 | |||
| fb73d8272e | |||
| 87321fb669 | |||
| 44b60d5070 | |||
| 89897aa48f | |||
| b2879211e4 |
67
.gitea/workflows/rust.yml
Normal file
67
.gitea/workflows/rust.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
on: [push]
|
||||||
|
|
||||||
|
name: Continuous integration
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
- run: cargo check
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test Suite
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
- run: cargo test
|
||||||
|
|
||||||
|
trunk:
|
||||||
|
name: Trunk
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: nightly
|
||||||
|
target: wasm32-unknown-unknown
|
||||||
|
- run: cargo install trunk
|
||||||
|
- run: cd web; trunk build
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
name: Rustfmt
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- name: Rustfmt Check
|
||||||
|
uses: actions-rust-lang/rustfmt@v1
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
- run: cargo build
|
||||||
|
|
||||||
|
udeps:
|
||||||
|
name: Disallow unused dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: nightly
|
||||||
|
|
||||||
|
- name: Run cargo-udeps
|
||||||
|
uses: aig787/cargo-udeps-action@v1
|
||||||
|
with:
|
||||||
|
version: 'latest'
|
||||||
|
args: '--all-targets'
|
||||||
|
|
||||||
3189
Cargo.lock
generated
3189
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,14 @@ resolver = "2"
|
|||||||
default-members = ["server"]
|
default-members = ["server"]
|
||||||
members = ["web", "server", "notmuch", "procmail2notmuch", "shared"]
|
members = ["web", "server", "notmuch", "procmail2notmuch", "shared"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
authors = ["Bill Thiede <git@xinu.tv>"]
|
||||||
|
edition = "2021"
|
||||||
|
license = "UNLICENSED"
|
||||||
|
publish = ["xinu"]
|
||||||
|
version = "0.17.17"
|
||||||
|
repository = "https://git.z.xinu.tv/wathiede/letterbox"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
|
|
||||||
|
|||||||
19
Justfile
Normal file
19
Justfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export CARGO_INCREMENTAL := "0"
|
||||||
|
export RUSTFLAGS := "-D warnings"
|
||||||
|
|
||||||
|
default:
|
||||||
|
@echo "Run: just patch|minor|major"
|
||||||
|
|
||||||
|
major: (_release "major")
|
||||||
|
minor: (_release "minor")
|
||||||
|
patch: (_release "patch")
|
||||||
|
|
||||||
|
sqlx-prepare:
|
||||||
|
cd server; cargo sqlx prepare && git add .sqlx; git commit -m "cargo sqlx prepare" .sqlx || true
|
||||||
|
|
||||||
|
pull:
|
||||||
|
git pull
|
||||||
|
|
||||||
|
|
||||||
|
_release level: pull sqlx-prepare
|
||||||
|
cargo-release release -x {{ level }} --workspace --no-confirm --registry=xinu
|
||||||
2
dev.sh
2
dev.sh
@@ -3,5 +3,5 @@ tmux new-session -d -s letterbox-dev
|
|||||||
tmux rename-window web
|
tmux rename-window web
|
||||||
tmux send-keys "cd web; trunk serve -w ../.git -w ../shared -w ../notmuch -w ./" C-m
|
tmux send-keys "cd web; trunk serve -w ../.git -w ../shared -w ../notmuch -w ./" C-m
|
||||||
tmux new-window -n server
|
tmux new-window -n server
|
||||||
tmux send-keys "cd server; cargo watch -c -x run -w ../.git -w ../shared -w ../notmuch -w ./" C-m
|
tmux send-keys "cd server; cargo watch -c -w ../.git -w ../shared -w ../notmuch -w ./ -x 'run postgres://newsreader@nixos-07.h.xinu.tv/newsreader ../target/database/newsreader /tmp/letterbox/slurp'" C-m
|
||||||
tmux attach -d -t letterbox-dev
|
tmux attach -d -t letterbox-dev
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "notmuch"
|
name = "letterbox-notmuch"
|
||||||
version = "0.0.115"
|
exclude = ["/testdata"]
|
||||||
edition = "2021"
|
description = "Wrapper for calling notmuch cli"
|
||||||
|
authors.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = "0.4.14"
|
log = "0.4.27"
|
||||||
|
mailparse = "0.16.1"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = { version = "1.0", features = ["unbounded_depth"] }
|
serde_json = { version = "1.0", features = ["unbounded_depth"] }
|
||||||
thiserror = "1.0.30"
|
thiserror = "2.0.12"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
itertools = "0.10.1"
|
itertools = "0.14.0"
|
||||||
pretty_assertions = "1"
|
pretty_assertions = "1"
|
||||||
rayon = "1.5"
|
rayon = "1.10"
|
||||||
|
|||||||
@@ -207,6 +207,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
io::{self},
|
io::{self},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -270,6 +271,12 @@ pub struct Headers {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub bcc: Option<String>,
|
pub bcc: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(alias = "Delivered-To")]
|
||||||
|
pub delivered_to: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(alias = "X-Original-To")]
|
||||||
|
pub x_original_to: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub reply_to: Option<String>,
|
pub reply_to: Option<String>,
|
||||||
pub date: String,
|
pub date: String,
|
||||||
}
|
}
|
||||||
@@ -459,9 +466,11 @@ pub enum NotmuchError {
|
|||||||
StringUtf8Error(#[from] std::string::FromUtf8Error),
|
StringUtf8Error(#[from] std::string::FromUtf8Error),
|
||||||
#[error("failed to parse str as int")]
|
#[error("failed to parse str as int")]
|
||||||
ParseIntError(#[from] std::num::ParseIntError),
|
ParseIntError(#[from] std::num::ParseIntError),
|
||||||
|
#[error("failed to parse mail: {0}")]
|
||||||
|
MailParseError(#[from] mailparse::MailParseError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct Notmuch {
|
pub struct Notmuch {
|
||||||
config_path: Option<PathBuf>,
|
config_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
@@ -494,15 +503,28 @@ impl Notmuch {
|
|||||||
self.tags_for_query("*")
|
self.tags_for_query("*")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all, fields(tag=tag,search_term=search_term))]
|
|
||||||
pub fn tag_add(&self, tag: &str, search_term: &str) -> Result<(), NotmuchError> {
|
pub fn tag_add(&self, tag: &str, search_term: &str) -> Result<(), NotmuchError> {
|
||||||
self.run_notmuch(["tag", &format!("+{tag}"), search_term])?;
|
self.tags_add(tag, &[search_term])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip_all, fields(tag=tag,search_term=?search_term))]
|
||||||
|
pub fn tags_add(&self, tag: &str, search_term: &[&str]) -> Result<(), NotmuchError> {
|
||||||
|
let tag = format!("+{tag}");
|
||||||
|
let mut args = vec!["tag", &tag];
|
||||||
|
args.extend(search_term);
|
||||||
|
self.run_notmuch(&args)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all, fields(tag=tag,search_term=search_term))]
|
|
||||||
pub fn tag_remove(&self, tag: &str, search_term: &str) -> Result<(), NotmuchError> {
|
pub fn tag_remove(&self, tag: &str, search_term: &str) -> Result<(), NotmuchError> {
|
||||||
self.run_notmuch(["tag", &format!("-{tag}"), search_term])?;
|
self.tags_remove(tag, &[search_term])
|
||||||
|
}
|
||||||
|
#[instrument(skip_all, fields(tag=tag,search_term=?search_term))]
|
||||||
|
pub fn tags_remove(&self, tag: &str, search_term: &[&str]) -> Result<(), NotmuchError> {
|
||||||
|
let tag = format!("-{tag}");
|
||||||
|
let mut args = vec!["tag", &tag];
|
||||||
|
args.extend(search_term);
|
||||||
|
self.run_notmuch(&args)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +611,11 @@ impl Notmuch {
|
|||||||
|
|
||||||
#[instrument(skip_all, fields(id=id,part=part))]
|
#[instrument(skip_all, fields(id=id,part=part))]
|
||||||
pub fn show_original_part(&self, id: &MessageId, part: usize) -> Result<Vec<u8>, NotmuchError> {
|
pub fn show_original_part(&self, id: &MessageId, part: usize) -> Result<Vec<u8>, NotmuchError> {
|
||||||
|
let id = if id.starts_with("id:") {
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
&format!("id:{id}")
|
||||||
|
};
|
||||||
let res = self.run_notmuch(["show", "--part", &part.to_string(), id])?;
|
let res = self.run_notmuch(["show", "--part", &part.to_string(), id])?;
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
@@ -605,6 +632,80 @@ impl Notmuch {
|
|||||||
Ok(serde_json::from_slice(&res)?)
|
Ok(serde_json::from_slice(&res)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip_all)]
|
||||||
|
pub fn unread_recipients(&self) -> Result<HashMap<String, usize>, NotmuchError> {
|
||||||
|
let slice = self.run_notmuch([
|
||||||
|
"show",
|
||||||
|
"--include-html=false",
|
||||||
|
"--entire-thread=false",
|
||||||
|
"--body=false",
|
||||||
|
"--format=json",
|
||||||
|
// Arbitrary limit to prevent too much work
|
||||||
|
"--limit=1000",
|
||||||
|
"is:unread",
|
||||||
|
])?;
|
||||||
|
// Notmuch returns JSON with invalid unicode. So we lossy convert it to a string here and
|
||||||
|
// use that for parsing in rust.
|
||||||
|
let s = String::from_utf8_lossy(&slice);
|
||||||
|
let mut deserializer = serde_json::Deserializer::from_str(&s);
|
||||||
|
deserializer.disable_recursion_limit();
|
||||||
|
let ts: ThreadSet = serde::de::Deserialize::deserialize(&mut deserializer)?;
|
||||||
|
deserializer.end()?;
|
||||||
|
let mut r = HashMap::new();
|
||||||
|
fn collect_from_thread_node(
|
||||||
|
r: &mut HashMap<String, usize>,
|
||||||
|
tn: &ThreadNode,
|
||||||
|
) -> Result<(), NotmuchError> {
|
||||||
|
let Some(msg) = &tn.0 else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut addrs = vec![];
|
||||||
|
let hdr = &msg.headers.to;
|
||||||
|
if let Some(to) = hdr {
|
||||||
|
addrs.push(to);
|
||||||
|
} else {
|
||||||
|
let hdr = &msg.headers.x_original_to;
|
||||||
|
if let Some(to) = hdr {
|
||||||
|
addrs.push(to);
|
||||||
|
} else {
|
||||||
|
let hdr = &msg.headers.delivered_to;
|
||||||
|
if let Some(to) = hdr {
|
||||||
|
addrs.push(to);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let hdr = &msg.headers.cc;
|
||||||
|
if let Some(cc) = hdr {
|
||||||
|
addrs.push(cc);
|
||||||
|
};
|
||||||
|
for recipient in addrs {
|
||||||
|
mailparse::addrparse(&recipient)?
|
||||||
|
.into_inner()
|
||||||
|
.iter()
|
||||||
|
.for_each(|a| {
|
||||||
|
let mailparse::MailAddr::Single(si) = a else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let addr = &si.addr;
|
||||||
|
|
||||||
|
if addr == "couchmoney@gmail.com" || addr.ends_with("@xinu.tv") {
|
||||||
|
*r.entry(addr.to_lowercase()).or_default() += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
for t in ts.0 {
|
||||||
|
for tn in t.0 {
|
||||||
|
collect_from_thread_node(&mut r, &tn)?;
|
||||||
|
for sub_tn in tn.1 {
|
||||||
|
collect_from_thread_node(&mut r, &sub_tn)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>, NotmuchError>
|
fn run_notmuch<I, S>(&self, args: I) -> Result<Vec<u8>, NotmuchError>
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::{
|
|||||||
time::Instant,
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
use notmuch::Notmuch;
|
use letterbox_notmuch::Notmuch;
|
||||||
use rayon::iter::{ParallelBridge, ParallelIterator};
|
use rayon::iter::{ParallelBridge, ParallelIterator};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "procmail2notmuch"
|
name = "letterbox-procmail2notmuch"
|
||||||
version = "0.0.115"
|
description = "Tool for generating notmuch rules from procmail"
|
||||||
edition = "2021"
|
authors.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.69"
|
anyhow = "1.0.98"
|
||||||
|
clap = { version = "4.5.37", features = ["derive", "env"] }
|
||||||
|
letterbox-notmuch = { version = "0.17.9", registry = "xinu" }
|
||||||
|
letterbox-shared = { version = "0.17.9", registry = "xinu" }
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
sqlx = { version = "0.8.5", features = ["postgres", "runtime-tokio"] }
|
||||||
|
tokio = { version = "1.44.2", features = ["rt", "macros", "rt-multi-thread"] }
|
||||||
|
|||||||
@@ -1,210 +1,36 @@
|
|||||||
use std::{convert::Infallible, io::Write, str::FromStr};
|
use std::{collections::HashMap, io::Write};
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
use clap::{Parser, Subcommand};
|
||||||
enum MatchType {
|
use letterbox_shared::{cleanup_match, Match, MatchType, Rule};
|
||||||
From,
|
use sqlx::{types::Json, PgPool};
|
||||||
Sender,
|
|
||||||
To,
|
#[derive(Debug, Subcommand)]
|
||||||
Cc,
|
enum Mode {
|
||||||
Subject,
|
Debug,
|
||||||
List,
|
Notmuchrc,
|
||||||
DeliveredTo,
|
LoadSql {
|
||||||
XForwardedTo,
|
#[arg(short, long)]
|
||||||
ReplyTo,
|
dsn: String,
|
||||||
XOriginalTo,
|
},
|
||||||
XSpam,
|
|
||||||
Body,
|
|
||||||
#[default]
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct Match {
|
|
||||||
match_type: MatchType,
|
|
||||||
needle: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
/// Simple program to greet a person
|
||||||
struct Rule {
|
#[derive(Parser, Debug)]
|
||||||
matches: Vec<Match>,
|
#[command(version, about, long_about = None)]
|
||||||
tags: Vec<String>,
|
struct Args {
|
||||||
|
#[arg(short, long, default_value = "/home/wathiede/dotfiles/procmailrc")]
|
||||||
|
input: String,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
mode: Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unescape(s: &str) -> String {
|
#[tokio::main]
|
||||||
s.replace('\\', "")
|
async fn main() -> anyhow::Result<()> {
|
||||||
}
|
let args = Args::parse();
|
||||||
|
|
||||||
fn cleanup_match(prefix: &str, s: &str) -> String {
|
|
||||||
unescape(&s[prefix.len()..]).replace(".*", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
mod matches {
|
|
||||||
pub const TO: &'static str = "TO";
|
|
||||||
pub const CC: &'static str = "Cc";
|
|
||||||
pub const TOCC: &'static str = "(TO|Cc)";
|
|
||||||
pub const FROM: &'static str = "From";
|
|
||||||
pub const SENDER: &'static str = "Sender";
|
|
||||||
pub const SUBJECT: &'static str = "Subject";
|
|
||||||
pub const DELIVERED_TO: &'static str = "Delivered-To";
|
|
||||||
pub const X_FORWARDED_TO: &'static str = "X-Forwarded-To";
|
|
||||||
pub const REPLY_TO: &'static str = "Reply-To";
|
|
||||||
pub const X_ORIGINAL_TO: &'static str = "X-Original-To";
|
|
||||||
pub const LIST_ID: &'static str = "List-ID";
|
|
||||||
pub const X_SPAM: &'static str = "X-Spam";
|
|
||||||
pub const X_SPAM_FLAG: &'static str = "X-Spam-Flag";
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for Match {
|
|
||||||
type Err = Infallible;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
// Examples:
|
|
||||||
// "* 1^0 ^TOsonyrewards.com@xinu.tv"
|
|
||||||
// "* ^TOsonyrewards.com@xinu.tv"
|
|
||||||
let mut it = s.split_whitespace().skip(1);
|
|
||||||
let mut needle = it.next().unwrap();
|
|
||||||
if needle == "1^0" {
|
|
||||||
needle = it.next().unwrap();
|
|
||||||
}
|
|
||||||
let mut needle = vec![needle];
|
|
||||||
needle.extend(it);
|
|
||||||
let needle = needle.join(" ");
|
|
||||||
let first = needle.chars().nth(0).unwrap_or(' ');
|
|
||||||
use matches::*;
|
|
||||||
if first == '^' {
|
|
||||||
let needle = &needle[1..];
|
|
||||||
if needle.starts_with(TO) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::To,
|
|
||||||
needle: cleanup_match(TO, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(FROM) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::From,
|
|
||||||
needle: cleanup_match(FROM, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(CC) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::Cc,
|
|
||||||
needle: cleanup_match(CC, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(TOCC) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::To,
|
|
||||||
needle: cleanup_match(TOCC, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(SENDER) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::Sender,
|
|
||||||
needle: cleanup_match(SENDER, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(SUBJECT) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::Subject,
|
|
||||||
needle: cleanup_match(SUBJECT, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(X_ORIGINAL_TO) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::XOriginalTo,
|
|
||||||
needle: cleanup_match(X_ORIGINAL_TO, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(LIST_ID) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::List,
|
|
||||||
needle: cleanup_match(LIST_ID, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(REPLY_TO) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::ReplyTo,
|
|
||||||
needle: cleanup_match(REPLY_TO, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(X_SPAM_FLAG) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::XSpam,
|
|
||||||
needle: '*'.to_string(),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(X_SPAM) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::XSpam,
|
|
||||||
needle: '*'.to_string(),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(DELIVERED_TO) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::DeliveredTo,
|
|
||||||
needle: cleanup_match(DELIVERED_TO, needle),
|
|
||||||
});
|
|
||||||
} else if needle.starts_with(X_FORWARDED_TO) {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::XForwardedTo,
|
|
||||||
needle: cleanup_match(X_FORWARDED_TO, needle),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
unreachable!("needle: '{needle}'")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Ok(Match {
|
|
||||||
match_type: MatchType::Body,
|
|
||||||
needle: cleanup_match("", &needle),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn notmuch_from_rules<W: Write>(mut w: W, rules: &[Rule]) -> anyhow::Result<()> {
|
|
||||||
// TODO(wathiede): if reindexing this many tags is too slow, see if combining rules per tag is
|
|
||||||
// faster.
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
for r in rules {
|
|
||||||
for m in &r.matches {
|
|
||||||
for t in &r.tags {
|
|
||||||
if let MatchType::Unknown = m.match_type {
|
|
||||||
eprintln!("rule has unknown match {:?}", r);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rule = match m.match_type {
|
|
||||||
MatchType::From => "from:",
|
|
||||||
// TODO(wathiede): something more specific?
|
|
||||||
MatchType::Sender => "from:",
|
|
||||||
MatchType::To => "to:",
|
|
||||||
MatchType::Cc => "to:",
|
|
||||||
MatchType::Subject => "subject:",
|
|
||||||
MatchType::List => "List-ID:",
|
|
||||||
MatchType::Body => "",
|
|
||||||
// TODO(wathiede): these will probably require adding fields to notmuch
|
|
||||||
// index. Handle them later.
|
|
||||||
MatchType::DeliveredTo
|
|
||||||
| MatchType::XForwardedTo
|
|
||||||
| MatchType::ReplyTo
|
|
||||||
| MatchType::XOriginalTo
|
|
||||||
| MatchType::XSpam => continue,
|
|
||||||
MatchType::Unknown => unreachable!(),
|
|
||||||
};
|
|
||||||
// Preserve unread status if run with --remove-all
|
|
||||||
lines.push(format!(
|
|
||||||
r#"-unprocessed +{} +unread -- is:unread tag:unprocessed {}"{}""#,
|
|
||||||
t, rule, m.needle
|
|
||||||
));
|
|
||||||
lines.push(format!(
|
|
||||||
// TODO(wathiede): this assumes `notmuch new` is configured to add
|
|
||||||
// `tag:unprocessed` to all new mail.
|
|
||||||
r#"-unprocessed +{} -- tag:unprocessed {}"{}""#,
|
|
||||||
t, rule, m.needle
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.sort();
|
|
||||||
for l in lines {
|
|
||||||
writeln!(w, "{l}")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
|
||||||
let input = "/home/wathiede/dotfiles/procmailrc";
|
|
||||||
let mut rules = Vec::new();
|
let mut rules = Vec::new();
|
||||||
let mut cur_rule = Rule::default();
|
let mut cur_rule = Rule::default();
|
||||||
for l in std::fs::read_to_string(input)?.lines() {
|
for l in std::fs::read_to_string(args.input)?.lines() {
|
||||||
let l = if let Some(idx) = l.find('#') {
|
let l = if let Some(idx) = l.find('#') {
|
||||||
&l[..idx]
|
&l[..idx]
|
||||||
} else {
|
} else {
|
||||||
@@ -222,6 +48,9 @@ fn main() -> anyhow::Result<()> {
|
|||||||
match first {
|
match first {
|
||||||
':' => {
|
':' => {
|
||||||
// start of rule
|
// start of rule
|
||||||
|
|
||||||
|
// If carbon-copy flag present, don't stop on match
|
||||||
|
cur_rule.stop_on_match = !l.contains('c');
|
||||||
}
|
}
|
||||||
'*' => {
|
'*' => {
|
||||||
// add to current rule
|
// add to current rule
|
||||||
@@ -230,26 +59,119 @@ fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
'.' => {
|
'.' => {
|
||||||
// delivery to folder
|
// delivery to folder
|
||||||
cur_rule.tags.push(cleanup_match(
|
cur_rule.tag = cleanup_match(
|
||||||
"",
|
"",
|
||||||
&l.replace('.', "/")
|
&l.replace('.', "/")
|
||||||
.replace(' ', "")
|
.replace(' ', "")
|
||||||
.trim_matches('/')
|
.trim_matches('/')
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
);
|
||||||
rules.push(cur_rule);
|
rules.push(cur_rule);
|
||||||
cur_rule = Rule::default();
|
cur_rule = Rule::default();
|
||||||
}
|
}
|
||||||
|
'/' => cur_rule = Rule::default(), // Ex. /dev/null
|
||||||
'|' => cur_rule = Rule::default(), // external command
|
'|' => cur_rule = Rule::default(), // external command
|
||||||
'$' => {
|
'$' => {
|
||||||
// TODO(wathiede): tag messages with no other tag as 'inbox'
|
// TODO(wathiede): tag messages with no other tag as 'inbox'
|
||||||
cur_rule.tags.push(cleanup_match("", "inbox"));
|
cur_rule.tag = cleanup_match("", "inbox");
|
||||||
rules.push(cur_rule);
|
rules.push(cur_rule);
|
||||||
cur_rule = Rule::default();
|
cur_rule = Rule::default();
|
||||||
} // variable, should only be $DEFAULT in my config
|
} // variable, should only be $DEFAULT in my config
|
||||||
_ => panic!("Unhandled first character '{}' {}", first, l),
|
_ => panic!("Unhandled first character '{}'\nLine: {}", first, l),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notmuch_from_rules(std::io::stdout(), &rules)?;
|
match args.mode {
|
||||||
|
Mode::Debug => print_rules(&rules),
|
||||||
|
Mode::Notmuchrc => notmuch_from_rules(std::io::stdout(), &rules)?,
|
||||||
|
Mode::LoadSql { dsn } => load_sql(&dsn, &rules).await?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_rules(rules: &[Rule]) {
|
||||||
|
let mut tally = HashMap::new();
|
||||||
|
for r in rules {
|
||||||
|
for m in &r.matches {
|
||||||
|
*tally.entry(m.match_type).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut sorted: Vec<_> = tally.iter().map(|(k, v)| (v, k)).collect();
|
||||||
|
sorted.sort();
|
||||||
|
sorted.reverse();
|
||||||
|
for (v, k) in sorted {
|
||||||
|
println!("{k:?}: {v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notmuch_from_rules<W: Write>(mut w: W, rules: &[Rule]) -> anyhow::Result<()> {
|
||||||
|
// TODO(wathiede): if reindexing this many tags is too slow, see if combining rules per tag is
|
||||||
|
// faster.
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for r in rules {
|
||||||
|
for m in &r.matches {
|
||||||
|
let t = &r.tag;
|
||||||
|
if let MatchType::Unknown = m.match_type {
|
||||||
|
eprintln!("rule has unknown match {:?}", r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rule = match m.match_type {
|
||||||
|
MatchType::From => "from:",
|
||||||
|
// TODO(wathiede): something more specific?
|
||||||
|
MatchType::Sender => "from:",
|
||||||
|
MatchType::To => "to:",
|
||||||
|
MatchType::Cc => "to:",
|
||||||
|
MatchType::Subject => "subject:",
|
||||||
|
MatchType::ListId => "List-ID:",
|
||||||
|
MatchType::Body => "",
|
||||||
|
// TODO(wathiede): these will probably require adding fields to notmuch
|
||||||
|
// index. Handle them later.
|
||||||
|
MatchType::DeliveredTo
|
||||||
|
| MatchType::XForwardedTo
|
||||||
|
| MatchType::ReplyTo
|
||||||
|
| MatchType::XOriginalTo
|
||||||
|
| MatchType::XSpam => continue,
|
||||||
|
MatchType::Unknown => unreachable!(),
|
||||||
|
};
|
||||||
|
// Preserve unread status if run with --remove-all
|
||||||
|
lines.push(format!(
|
||||||
|
r#"-unprocessed +{} +unread -- is:unread tag:unprocessed {}"{}""#,
|
||||||
|
t, rule, m.needle
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
// TODO(wathiede): this assumes `notmuch new` is configured to add
|
||||||
|
// `tag:unprocessed` to all new mail.
|
||||||
|
r#"-unprocessed +{} -- tag:unprocessed {}"{}""#,
|
||||||
|
t, rule, m.needle
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.sort();
|
||||||
|
for l in lines {
|
||||||
|
writeln!(w, "{l}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_sql(dsn: &str, rules: &[Rule]) -> anyhow::Result<()> {
|
||||||
|
let pool = PgPool::connect(dsn).await?;
|
||||||
|
println!("clearing email_rule table");
|
||||||
|
sqlx::query!("DELETE FROM email_rule")
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (order, rule) in rules.iter().enumerate() {
|
||||||
|
println!("inserting {order}: {rule:?}");
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO email_rule (sort_order, rule)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
"#,
|
||||||
|
order as i32,
|
||||||
|
Json(rule) as _
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
6
renovate.json
Normal file
6
renovate.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT\n date,\n is_read,\n link,\n site,\n summary,\n title,\n name,\n homepage\nFROM\n post p\n JOIN feed f ON p.site = f.slug\nWHERE\n uid = $1\n",
|
"query": "SELECT\n date,\n is_read,\n link,\n site,\n summary,\n clean_summary,\n title,\n name,\n homepage\nFROM\n post AS p\nINNER JOIN feed AS f ON p.site = f.slug\nWHERE\n uid = $1\n",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -30,16 +30,21 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"name": "title",
|
"name": "clean_summary",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"name": "name",
|
"name": "title",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
@@ -57,8 +62,9 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "113694cd5bf0d2582ff3a635776daa608fe88abe1185958c4215646c92335afb"
|
"hash": "383221a94bc3746322ba78e41cde37994440ee67dc32e88d2394c51211bde6cd"
|
||||||
}
|
}
|
||||||
32
server/.sqlx/query-3d271b404f06497a5dcde68cf6bf07291d70fa56058ea736ac24e91d33050c04.json
generated
Normal file
32
server/.sqlx/query-3d271b404f06497a5dcde68cf6bf07291d70fa56058ea736ac24e91d33050c04.json
generated
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT\n p.id,\n link,\n clean_summary\nFROM\n post AS p\nINNER JOIN feed AS f ON p.site = f.slug -- necessary to weed out nzb posts\nWHERE\n search_summary IS NULL\n -- TODO remove AND link ~ '^<'\nORDER BY\n ROW_NUMBER() OVER (PARTITION BY site ORDER BY date DESC)\nLIMIT 100;\n",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "link",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "clean_summary",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3d271b404f06497a5dcde68cf6bf07291d70fa56058ea736ac24e91d33050c04"
|
||||||
|
}
|
||||||
20
server/.sqlx/query-6c5b0a96f45f78795732ea428cc01b4eab28b7150aa37387e7439a6b0b58e88c.json
generated
Normal file
20
server/.sqlx/query-6c5b0a96f45f78795732ea428cc01b4eab28b7150aa37387e7439a6b0b58e88c.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT rule as \"rule: Json<Rule>\"\n FROM email_rule\n ORDER BY sort_order\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "rule: Json<Rule>",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6c5b0a96f45f78795732ea428cc01b4eab28b7150aa37387e7439a6b0b58e88c"
|
||||||
|
}
|
||||||
24
server/.sqlx/query-8c1b3c78649135e98b89092237750088433f7ff1b7c2ddeedec553406ea9f203.json
generated
Normal file
24
server/.sqlx/query-8c1b3c78649135e98b89092237750088433f7ff1b7c2ddeedec553406ea9f203.json
generated
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT COUNT(*) AS count\nFROM\n post\nWHERE\n (\n $1::text IS NULL\n OR site = $1\n )\n AND (\n NOT $2\n OR NOT is_read\n )\n AND (\n $3::text IS NULL\n OR TO_TSVECTOR('english', search_summary)\n @@ WEBSEARCH_TO_TSQUERY('english', $3)\n )\n",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "count",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Bool",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8c1b3c78649135e98b89092237750088433f7ff1b7c2ddeedec553406ea9f203"
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\nSELECT id\nFROM feed\nWHERE slug = $1\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Int4"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "dabd12987369cb273c0191d46645c376439d246d5a697340574c6afdac93d2cc"
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT\n COUNT(*) count\nFROM\n post\nWHERE\n (\n $1 :: text IS NULL\n OR site = $1\n )\n AND (\n NOT $2\n OR NOT is_read\n )\n AND (\n $3 :: text IS NULL\n OR to_tsvector('english', summary) @@ websearch_to_tsquery('english', $3)\n )\n",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "count",
|
|
||||||
"type_info": "Int8"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text",
|
|
||||||
"Bool",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "e118f546c628661023aa25803bb29affb6cd25eca63246e5ace5b90a845d76ac"
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\nINSERT INTO feed ( name, slug, url, homepage, selector )\nVALUES ( $1, $2, $3, '', '' )\nRETURNING id\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Int4"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text",
|
|
||||||
"Text",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "e2a448aaf4fe92fc1deda10bf844f6b9225d35758cba7c9f337c1a730aee41bd"
|
|
||||||
}
|
|
||||||
15
server/.sqlx/query-ef8327f039dbfa8f4e59b7a77a6411252a346bf51cf940024a17d9fbb2df173c.json
generated
Normal file
15
server/.sqlx/query-ef8327f039dbfa8f4e59b7a77a6411252a346bf51cf940024a17d9fbb2df173c.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE post SET search_summary = $1 WHERE id = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ef8327f039dbfa8f4e59b7a77a6411252a346bf51cf940024a17d9fbb2df173c"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT\n site,\n date,\n is_read,\n title,\n uid,\n name\nFROM\n post p\n JOIN feed f ON p.site = f.slug\nWHERE\n ($1::text IS NULL OR site = $1)\n AND (\n NOT $2\n OR NOT is_read\n )\n AND (\n $5 :: text IS NULL\n OR to_tsvector('english', summary) @@ websearch_to_tsquery('english', $5)\n )\nORDER BY\n date DESC,\n title OFFSET $3\nLIMIT\n $4\n",
|
"query": "SELECT\n site,\n date,\n is_read,\n title,\n uid,\n name\nFROM\n post p\n JOIN feed f ON p.site = f.slug\nWHERE\n ($1::text IS NULL OR site = $1)\n AND (\n NOT $2\n OR NOT is_read\n )\n AND (\n $5 :: text IS NULL\n OR to_tsvector('english', search_summary) @@ websearch_to_tsquery('english', $5)\n )\nORDER BY\n date DESC,\n title OFFSET $3\nLIMIT\n $4\n",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -52,5 +52,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "99114d4840067acb12d9a41ef036bdd8ecf87cfdde8ce4985821485816af5213"
|
"hash": "fc4607f02cc76a5f3a6629cce4507c74f52ae44820897b47365da3f339d1da06"
|
||||||
}
|
}
|
||||||
@@ -1,51 +1,59 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "letterbox-server"
|
name = "letterbox-server"
|
||||||
version = "0.0.115"
|
|
||||||
edition = "2021"
|
|
||||||
default-run = "letterbox-server"
|
default-run = "letterbox-server"
|
||||||
|
description = "Backend for letterbox"
|
||||||
|
authors.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ammonia = "3.3.0"
|
ammonia = "4.1.0"
|
||||||
anyhow = "1.0.79"
|
anyhow = "1.0.98"
|
||||||
async-graphql = { version = "7", features = ["log"] }
|
async-graphql = { version = "7", features = ["log"] }
|
||||||
async-graphql-rocket = "7"
|
async-graphql-axum = "7.0.16"
|
||||||
async-trait = "0.1.81"
|
async-trait = "0.1.88"
|
||||||
build-info = "0.0.38"
|
axum = { version = "0.8.3", features = ["ws"] }
|
||||||
cacher = { version = "0.1.0", registry = "xinu" }
|
axum-macros = "0.5.0"
|
||||||
chrono = "0.4.39"
|
build-info = "0.0.40"
|
||||||
clap = { version = "4.5.23", features = ["derive"] }
|
cacher = { version = "0.2.0", registry = "xinu" }
|
||||||
css-inline = "0.13.0"
|
chrono = "0.4.40"
|
||||||
|
clap = { version = "4.5.37", features = ["derive"] }
|
||||||
|
css-inline = "0.14.4"
|
||||||
|
futures = "0.3.31"
|
||||||
|
headers = "0.4.0"
|
||||||
html-escape = "0.2.13"
|
html-escape = "0.2.13"
|
||||||
|
letterbox-notmuch = { path = "../notmuch", version = "0.17.17", registry = "xinu" }
|
||||||
|
letterbox-shared = { path = "../shared", version = "0.17.17", registry = "xinu" }
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
log = "0.4.17"
|
log = "0.4.27"
|
||||||
lol_html = "1.2.0"
|
lol_html = "2.3.0"
|
||||||
mailparse = "0.15.0"
|
mailparse = "0.16.1"
|
||||||
maplit = "1.0.2"
|
maplit = "1.0.2"
|
||||||
memmap = "0.7.0"
|
memmap = "0.7.0"
|
||||||
notmuch = { path = "../notmuch" }
|
regex = "1.11.1"
|
||||||
opentelemetry = "0.27.1"
|
reqwest = { version = "0.12.15", features = ["blocking"] }
|
||||||
reqwest = { version = "0.12.7", features = ["blocking"] }
|
scraper = "0.23.1"
|
||||||
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
rocket_cors = "0.6.0"
|
serde_json = "1.0.140"
|
||||||
scraper = "0.20.0"
|
sqlx = { version = "0.8.5", features = ["postgres", "runtime-tokio", "time"] }
|
||||||
serde = { version = "1.0.147", features = ["derive"] }
|
tantivy = { version = "0.24.1", optional = true }
|
||||||
serde_json = "1.0.87"
|
thiserror = "2.0.12"
|
||||||
shared = { path = "../shared" }
|
tokio = "1.44.2"
|
||||||
sqlx = { version = "0.8.2", features = ["postgres", "runtime-tokio", "time"] }
|
tower-http = { version = "0.6.2", features = ["trace"] }
|
||||||
tantivy = { version = "0.22.0", optional = true }
|
|
||||||
thiserror = "1.0.37"
|
|
||||||
tokio = "1.26.0"
|
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
url = "2.5.2"
|
url = "2.5.4"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
#xtracing = { path = "../../xtracing" }
|
|
||||||
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
|
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
|
||||||
xtracing = { version = "0.1.0", registry = "xinu" }
|
#xtracing = { path = "../../xtracing" }
|
||||||
|
xtracing = { version = "0.3.2", registry = "xinu" }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
build-info-build = "0.0.38"
|
build-info-build = "0.0.40"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
#default = [ "tantivy" ]
|
#default = [ "tantivy" ]
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ newsreader_database_url = "postgres://newsreader@nixos-07.h.xinu.tv/newsreader"
|
|||||||
newsreader_tantivy_db_path = "../target/database/newsreader"
|
newsreader_tantivy_db_path = "../target/database/newsreader"
|
||||||
|
|
||||||
[debug]
|
[debug]
|
||||||
address = "0.0.0.0"
|
|
||||||
port = 9345
|
port = 9345
|
||||||
# Uncomment to make it production like.
|
# Uncomment to make it production like.
|
||||||
#log_level = "critical"
|
#log_level = "critical"
|
||||||
newsreader_database_url = "postgres://newsreader@nixos-07.h.xinu.tv/newsreader"
|
newsreader_database_url = "postgres://newsreader@nixos-07.h.xinu.tv/newsreader"
|
||||||
newsreader_tantivy_db_path = "../target/database/newsreader"
|
newsreader_tantivy_db_path = "../target/database/newsreader"
|
||||||
slurp_cache_path = "/net/nasx/x/letterbox/slurp"
|
slurp_cache_path = "/tmp/letterbox/slurp"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
DROP INDEX nzb_posts_created_at_idx;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
CREATE INDEX nzb_posts_created_at_idx ON nzb_posts USING btree (created_at);
|
||||||
15
server/migrations/20250128234348_add-search-summary.down.sql
Normal file
15
server/migrations/20250128234348_add-search-summary.down.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add down migration script here
|
||||||
|
BEGIN;
|
||||||
|
DROP INDEX IF EXISTS post_search_summary_idx;
|
||||||
|
ALTER TABLE post DROP search_summary;
|
||||||
|
|
||||||
|
-- CREATE INDEX post_summary_idx ON post USING gin (to_tsvector(
|
||||||
|
-- 'english',
|
||||||
|
-- regexp_replace(
|
||||||
|
-- regexp_replace(summary, '<[^>]+>', ' ', 'g'),
|
||||||
|
-- '\s+',
|
||||||
|
-- ' ',
|
||||||
|
-- 'g'
|
||||||
|
-- )
|
||||||
|
-- ));
|
||||||
|
COMMIT;
|
||||||
14
server/migrations/20250128234348_add-search-summary.up.sql
Normal file
14
server/migrations/20250128234348_add-search-summary.up.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Add up migration script here
|
||||||
|
BEGIN;
|
||||||
|
DROP INDEX IF EXISTS post_summary_idx;
|
||||||
|
ALTER TABLE post ADD search_summary TEXT;
|
||||||
|
CREATE INDEX post_search_summary_idx ON post USING gin (
|
||||||
|
to_tsvector('english', search_summary)
|
||||||
|
);
|
||||||
|
UPDATE post SET search_summary = regexp_replace(
|
||||||
|
regexp_replace(summary, '<[^>]+>', ' ', 'g'),
|
||||||
|
'\s+',
|
||||||
|
' ',
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
20
server/migrations/20250330175930_update-nzfinder-link.sql
Normal file
20
server/migrations/20250330175930_update-nzfinder-link.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Bad examples:
|
||||||
|
-- https://nzbfinder.ws/getnzb/d2c3e5a08abadd985dccc6a574122892030b6a9a.nzb&i=95972&r=b55082d289937c050dedc203c9653850
|
||||||
|
-- https://nzbfinder.ws/getnzb?id=45add174-7da4-4445-bf2b-a67dbbfc07fe.nzb&r=b55082d289937c050dedc203c9653850
|
||||||
|
-- https://nzbfinder.ws/api/v1/getnzb?id=82486020-c192-4fa0-a7e7-798d7d72e973.nzb&r=b55082d289937c050dedc203c9653850
|
||||||
|
UPDATE nzb_posts
|
||||||
|
SET link =
|
||||||
|
regexp_replace(
|
||||||
|
regexp_replace(
|
||||||
|
regexp_replace(
|
||||||
|
link,
|
||||||
|
'https://nzbfinder.ws/getnzb/',
|
||||||
|
'https://nzbfinder.ws/api/v1/getnzb?id='
|
||||||
|
),
|
||||||
|
'https://nzbfinder.ws/getnzb',
|
||||||
|
'https://nzbfinder.ws/api/v1/getnzb'
|
||||||
|
),
|
||||||
|
'&r=',
|
||||||
|
'&apikey='
|
||||||
|
)
|
||||||
|
;
|
||||||
3
server/migrations/20250419202131_email-rules.down.sql
Normal file
3
server/migrations/20250419202131_email-rules.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF NOT EXISTS email_rule;
|
||||||
|
|
||||||
|
-- Add down migration script here
|
||||||
5
server/migrations/20250419202131_email-rules.up.sql
Normal file
5
server/migrations/20250419202131_email-rules.up.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS email_rule (
|
||||||
|
id integer NOT NULL GENERATED ALWAYS AS IDENTITY,
|
||||||
|
sort_order integer NOT NULL,
|
||||||
|
rule jsonb NOT NULL
|
||||||
|
);
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
SELECT
|
SELECT COUNT(*) AS count
|
||||||
COUNT(*) count
|
|
||||||
FROM
|
FROM
|
||||||
post
|
post
|
||||||
WHERE
|
WHERE
|
||||||
(
|
(
|
||||||
$1 :: text IS NULL
|
$1::text IS NULL
|
||||||
OR site = $1
|
OR site = $1
|
||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
@@ -12,6 +11,7 @@ WHERE
|
|||||||
OR NOT is_read
|
OR NOT is_read
|
||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
$3 :: text IS NULL
|
$3::text IS NULL
|
||||||
OR to_tsvector('english', summary) @@ websearch_to_tsquery('english', $3)
|
OR TO_TSVECTOR('english', search_summary)
|
||||||
|
@@ WEBSEARCH_TO_TSQUERY('english', $3)
|
||||||
)
|
)
|
||||||
|
|||||||
13
server/sql/need-search-summary.sql
Normal file
13
server/sql/need-search-summary.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
link,
|
||||||
|
clean_summary
|
||||||
|
FROM
|
||||||
|
post AS p
|
||||||
|
INNER JOIN feed AS f ON p.site = f.slug -- necessary to weed out nzb posts
|
||||||
|
WHERE
|
||||||
|
search_summary IS NULL
|
||||||
|
-- TODO remove AND link ~ '^<'
|
||||||
|
ORDER BY
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY site ORDER BY date DESC)
|
||||||
|
LIMIT 100;
|
||||||
@@ -4,11 +4,12 @@ SELECT
|
|||||||
link,
|
link,
|
||||||
site,
|
site,
|
||||||
summary,
|
summary,
|
||||||
|
clean_summary,
|
||||||
title,
|
title,
|
||||||
name,
|
name,
|
||||||
homepage
|
homepage
|
||||||
FROM
|
FROM
|
||||||
post p
|
post AS p
|
||||||
JOIN feed f ON p.site = f.slug
|
INNER JOIN feed AS f ON p.site = f.slug
|
||||||
WHERE
|
WHERE
|
||||||
uid = $1
|
uid = $1
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ WHERE
|
|||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
$5 :: text IS NULL
|
$5 :: text IS NULL
|
||||||
OR to_tsvector('english', summary) @@ websearch_to_tsquery('english', $5)
|
OR to_tsvector('english', search_summary) @@ websearch_to_tsquery('english', $5)
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
date DESC,
|
date DESC,
|
||||||
|
|||||||
13
server/sql/too-long-for-search.sql
Normal file
13
server/sql/too-long-for-search.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
select t.id, tt.tokid, tt.alias, length(t.token), t.token from (
|
||||||
|
select id, (ts_parse('default',
|
||||||
|
-- regexp_replace(
|
||||||
|
-- regexp_replace(summary, '<[^>]+>', ' ', 'g'),
|
||||||
|
-- '\s+',
|
||||||
|
-- ' ',
|
||||||
|
-- 'g'
|
||||||
|
-- )
|
||||||
|
summary
|
||||||
|
)).* from post) t
|
||||||
|
inner join ts_token_type('default') tt
|
||||||
|
on t.tokid = tt.tokid
|
||||||
|
where length(token) >= 2*1024;
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use clap::Parser;
|
|
||||||
use letterbox_server::mail::read_mail_to_db;
|
|
||||||
use sqlx::postgres::PgPool;
|
|
||||||
|
|
||||||
/// Add certain emails as posts in newsfeed app.
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[command(author, version, about, long_about = None)]
|
|
||||||
struct Args {
|
|
||||||
/// DB URL, something like postgres://newsreader@nixos-07.h.xinu.tv/newsreader
|
|
||||||
#[arg(short, long)]
|
|
||||||
db_url: String,
|
|
||||||
/// path to parse
|
|
||||||
path: String,
|
|
||||||
}
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
let _guard = xtracing::init(env!("CARGO_BIN_NAME"))?;
|
|
||||||
let args = Args::parse();
|
|
||||||
let pool = PgPool::connect(&args.db_url).await?;
|
|
||||||
read_mail_to_db(&pool, &args.path).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
21
server/src/bin/inline_html.rs
Normal file
21
server/src/bin/inline_html.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
println!("PWD: {}", std::env::current_dir()?.display());
|
||||||
|
let _url = "https://slashdot.org/story/25/01/24/1813201/walgreens-replaced-fridge-doors-with-smart-screens-its-now-a-200-million-fiasco?utm_source=rss1.0mainlinkanon&utm_medium=feed";
|
||||||
|
let _url = "https://hackaday.com/2025/01/24/hackaday-podcast-episode-305-caustic-clocks-practice-bones-and-brick-layers/";
|
||||||
|
let _url = "https://theonion.com/monster-devastated-to-see-film-depicting-things-he-told-guillermo-del-toro-in-confidence/";
|
||||||
|
let _url = "https://trofi.github.io/posts/330-another-nix-language-nondeterminism-example.html";
|
||||||
|
let _url = "https://blog.cloudflare.com/ddos-threat-report-for-2024-q4/";
|
||||||
|
let url = "https://trofi.github.io/posts/330-another-nix-language-nondeterminism-example.html";
|
||||||
|
let body = reqwest::blocking::get(url)?.text()?;
|
||||||
|
let output = "/tmp/h2md/output.html";
|
||||||
|
let inliner = css_inline::CSSInliner::options()
|
||||||
|
.base_url(Url::parse(url).ok())
|
||||||
|
.build();
|
||||||
|
let inlined = inliner.inline(&body)?;
|
||||||
|
fs::write(output, inlined)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,113 +1,101 @@
|
|||||||
// Rocket generates a lot of warnings for handlers
|
// Rocket generates a lot of warnings for handlers
|
||||||
// TODO: figure out why
|
// TODO: figure out why
|
||||||
#![allow(unreachable_patterns)]
|
#![allow(unreachable_patterns)]
|
||||||
#[macro_use]
|
use std::{error::Error, net::SocketAddr, sync::Arc, time::Duration};
|
||||||
extern crate rocket;
|
|
||||||
use std::{error::Error, io::Cursor, str::FromStr};
|
|
||||||
|
|
||||||
use async_graphql::{extensions, http::GraphiQLSource, EmptySubscription, Schema};
|
use async_graphql::{extensions, http::GraphiQLSource, Schema};
|
||||||
use async_graphql_rocket::{GraphQLQuery, GraphQLRequest, GraphQLResponse};
|
use async_graphql_axum::{GraphQL, GraphQLSubscription};
|
||||||
|
//allows to extract the IP of connecting user
|
||||||
|
use axum::extract::connect_info::ConnectInfo;
|
||||||
|
use axum::{
|
||||||
|
extract::{self, ws::WebSocketUpgrade, Query, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{self, IntoResponse, Response},
|
||||||
|
routing::{any, get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use cacher::FilesystemCacher;
|
||||||
|
use clap::Parser;
|
||||||
|
use letterbox_notmuch::Notmuch;
|
||||||
#[cfg(feature = "tantivy")]
|
#[cfg(feature = "tantivy")]
|
||||||
use letterbox_server::tantivy::TantivyConnection;
|
use letterbox_server::tantivy::TantivyConnection;
|
||||||
use letterbox_server::{
|
use letterbox_server::{
|
||||||
config::Config,
|
graphql::{compute_catchup_ids, Attachment, MutationRoot, QueryRoot, SubscriptionRoot},
|
||||||
error::ServerError,
|
nm::{attachment_bytes, cid_attachment_bytes, label_unprocessed},
|
||||||
graphql::{Attachment, GraphqlSchema, Mutation, QueryRoot},
|
ws::ConnectionTracker,
|
||||||
nm::{attachment_bytes, cid_attachment_bytes},
|
|
||||||
};
|
};
|
||||||
use notmuch::{Notmuch, NotmuchError, ThreadSet};
|
use letterbox_shared::WebsocketMessage;
|
||||||
use rocket::{
|
use serde::Deserialize;
|
||||||
fairing::AdHoc,
|
|
||||||
http::{ContentType, Header},
|
|
||||||
request::Request,
|
|
||||||
response::{content, Debug, Responder},
|
|
||||||
serde::json::Json,
|
|
||||||
Response, State,
|
|
||||||
};
|
|
||||||
use rocket_cors::{AllowedHeaders, AllowedOrigins};
|
|
||||||
use sqlx::postgres::PgPool;
|
use sqlx::postgres::PgPool;
|
||||||
|
use tokio::{net::TcpListener, sync::Mutex};
|
||||||
|
use tower_http::trace::{DefaultMakeSpan, TraceLayer};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
#[get("/show/<query>/pretty")]
|
// Make our own error that wraps `ServerError`.
|
||||||
async fn show_pretty(
|
struct AppError(letterbox_server::ServerError);
|
||||||
nm: &State<Notmuch>,
|
|
||||||
query: &str,
|
// Tell axum how to convert `AppError` into a response.
|
||||||
) -> Result<Json<ThreadSet>, Debug<ServerError>> {
|
impl IntoResponse for AppError {
|
||||||
let query = urlencoding::decode(query).map_err(|e| ServerError::from(NotmuchError::from(e)))?;
|
fn into_response(self) -> Response {
|
||||||
let res = nm.show(&query).map_err(ServerError::from)?;
|
(
|
||||||
Ok(Json(res))
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Something went wrong: {}", self.0),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// This enables using `?` on functions that return `Result<_, letterbox_server::Error>` to turn them into
|
||||||
#[get("/show/<query>")]
|
// `Result<_, AppError>`. That way you don't need to do that manually.
|
||||||
async fn show(nm: &State<Notmuch>, query: &str) -> Result<Json<ThreadSet>, Debug<NotmuchError>> {
|
impl<E> From<E> for AppError
|
||||||
let query = urlencoding::decode(query).map_err(NotmuchError::from)?;
|
where
|
||||||
let res = nm.show(&query)?;
|
E: Into<letterbox_server::ServerError>,
|
||||||
Ok(Json(res))
|
{
|
||||||
}
|
fn from(err: E) -> Self {
|
||||||
|
Self(err.into())
|
||||||
struct InlineAttachmentResponder(Attachment);
|
|
||||||
|
|
||||||
impl<'r, 'o: 'r> Responder<'r, 'o> for InlineAttachmentResponder {
|
|
||||||
fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'o> {
|
|
||||||
let mut resp = Response::build();
|
|
||||||
if let Some(filename) = self.0.filename {
|
|
||||||
resp.header(Header::new(
|
|
||||||
"Content-Disposition",
|
|
||||||
format!(r#"inline; filename="{}""#, filename),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(content_type) = self.0.content_type {
|
|
||||||
if let Some(ct) = ContentType::parse_flexible(&content_type) {
|
|
||||||
resp.header(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp.sized_body(self.0.bytes.len(), Cursor::new(self.0.bytes))
|
|
||||||
.ok()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DownloadAttachmentResponder(Attachment);
|
fn inline_attachment_response(attachment: Attachment) -> impl IntoResponse {
|
||||||
|
info!("attachment filename {:?}", attachment.filename);
|
||||||
impl<'r, 'o: 'r> Responder<'r, 'o> for DownloadAttachmentResponder {
|
let mut hdr_map = headers::HeaderMap::new();
|
||||||
fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'o> {
|
if let Some(filename) = attachment.filename {
|
||||||
let mut resp = Response::build();
|
hdr_map.insert(
|
||||||
if let Some(filename) = self.0.filename {
|
header::CONTENT_DISPOSITION,
|
||||||
resp.header(Header::new(
|
format!(r#"inline; filename="{}""#, filename)
|
||||||
"Content-Disposition",
|
.parse()
|
||||||
format!(r#"attachment; filename="{}""#, filename),
|
.unwrap(),
|
||||||
));
|
);
|
||||||
}
|
|
||||||
if let Some(content_type) = self.0.content_type {
|
|
||||||
if let Some(ct) = ContentType::parse_flexible(&content_type) {
|
|
||||||
resp.header(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp.sized_body(self.0.bytes.len(), Cursor::new(self.0.bytes))
|
|
||||||
.ok()
|
|
||||||
}
|
}
|
||||||
|
if let Some(ct) = attachment.content_type {
|
||||||
|
hdr_map.insert(header::CONTENT_TYPE, ct.parse().unwrap());
|
||||||
|
}
|
||||||
|
info!("hdr_map {hdr_map:?}");
|
||||||
|
(hdr_map, attachment.bytes).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/cid/<id>/<cid>")]
|
fn download_attachment_response(attachment: Attachment) -> impl IntoResponse {
|
||||||
async fn view_cid(
|
info!("attachment filename {:?}", attachment.filename);
|
||||||
nm: &State<Notmuch>,
|
let mut hdr_map = headers::HeaderMap::new();
|
||||||
id: &str,
|
if let Some(filename) = attachment.filename {
|
||||||
cid: &str,
|
hdr_map.insert(
|
||||||
) -> Result<InlineAttachmentResponder, Debug<ServerError>> {
|
header::CONTENT_DISPOSITION,
|
||||||
let mid = if id.starts_with("id:") {
|
format!(r#"attachment; filename="{}""#, filename)
|
||||||
id.to_string()
|
.parse()
|
||||||
} else {
|
.unwrap(),
|
||||||
format!("id:{}", id)
|
);
|
||||||
};
|
}
|
||||||
info!("view cid attachment {mid} {cid}");
|
if let Some(ct) = attachment.content_type {
|
||||||
let attachment = cid_attachment_bytes(nm, &mid, &cid)?;
|
hdr_map.insert(header::CONTENT_TYPE, ct.parse().unwrap());
|
||||||
Ok(InlineAttachmentResponder(attachment))
|
}
|
||||||
|
info!("hdr_map {hdr_map:?}");
|
||||||
|
(hdr_map, attachment.bytes).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/view/attachment/<id>/<idx>/<_>")]
|
#[axum_macros::debug_handler]
|
||||||
async fn view_attachment(
|
async fn view_attachment(
|
||||||
nm: &State<Notmuch>,
|
State(AppState { nm, .. }): State<AppState>,
|
||||||
id: &str,
|
extract::Path((id, idx, _)): extract::Path<(String, String, String)>,
|
||||||
idx: &str,
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
) -> Result<InlineAttachmentResponder, Debug<ServerError>> {
|
|
||||||
let mid = if id.starts_with("id:") {
|
let mid = if id.starts_with("id:") {
|
||||||
id.to_string()
|
id.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -118,16 +106,14 @@ async fn view_attachment(
|
|||||||
.split('.')
|
.split('.')
|
||||||
.map(|s| s.parse().expect("not a usize"))
|
.map(|s| s.parse().expect("not a usize"))
|
||||||
.collect();
|
.collect();
|
||||||
let attachment = attachment_bytes(nm, &mid, &idx)?;
|
let attachment = attachment_bytes(&nm, &mid, &idx)?;
|
||||||
Ok(InlineAttachmentResponder(attachment))
|
Ok(inline_attachment_response(attachment))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/download/attachment/<id>/<idx>/<_>")]
|
|
||||||
async fn download_attachment(
|
async fn download_attachment(
|
||||||
nm: &State<Notmuch>,
|
State(AppState { nm, .. }): State<AppState>,
|
||||||
id: &str,
|
extract::Path((id, idx, _)): extract::Path<(String, String, String)>,
|
||||||
idx: &str,
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
) -> Result<DownloadAttachmentResponder, Debug<ServerError>> {
|
|
||||||
let mid = if id.starts_with("id:") {
|
let mid = if id.starts_with("id:") {
|
||||||
id.to_string()
|
id.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -138,101 +124,220 @@ async fn download_attachment(
|
|||||||
.split('.')
|
.split('.')
|
||||||
.map(|s| s.parse().expect("not a usize"))
|
.map(|s| s.parse().expect("not a usize"))
|
||||||
.collect();
|
.collect();
|
||||||
let attachment = attachment_bytes(nm, &mid, &idx)?;
|
let attachment = attachment_bytes(&nm, &mid, &idx)?;
|
||||||
Ok(DownloadAttachmentResponder(attachment))
|
Ok(download_attachment_response(attachment))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/original/<id>")]
|
async fn view_cid(
|
||||||
async fn original(
|
State(AppState { nm, .. }): State<AppState>,
|
||||||
nm: &State<Notmuch>,
|
extract::Path((id, cid)): extract::Path<(String, String)>,
|
||||||
id: &str,
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
) -> Result<(ContentType, Vec<u8>), Debug<NotmuchError>> {
|
|
||||||
let mid = if id.starts_with("id:") {
|
let mid = if id.starts_with("id:") {
|
||||||
id.to_string()
|
id.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("id:{}", id)
|
format!("id:{}", id)
|
||||||
};
|
};
|
||||||
let res = nm.show_original(&mid)?;
|
info!("view cid attachment {mid} {cid}");
|
||||||
Ok((ContentType::Plain, res))
|
let attachment = cid_attachment_bytes(&nm, &mid, &cid)?;
|
||||||
|
Ok(inline_attachment_response(attachment))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::get("/")]
|
// TODO make this work with gitea message ids like `wathiede/letterbox/pulls/91@git.z.xinu.tv`
|
||||||
fn graphiql() -> content::RawHtml<String> {
|
async fn view_original(
|
||||||
content::RawHtml(GraphiQLSource::build().endpoint("/api/graphql").finish())
|
State(AppState { nm, .. }): State<AppState>,
|
||||||
|
extract::Path(id): extract::Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
info!("view_original {id}");
|
||||||
|
let bytes = nm.show_original(&id)?;
|
||||||
|
let s = String::from_utf8_lossy(&bytes).to_string();
|
||||||
|
Ok(s.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::get("/graphql?<query..>")]
|
async fn graphiql() -> impl IntoResponse {
|
||||||
async fn graphql_query(schema: &State<GraphqlSchema>, query: GraphQLQuery) -> GraphQLResponse {
|
response::Html(
|
||||||
query.execute(schema.inner()).await
|
GraphiQLSource::build()
|
||||||
|
.endpoint("/api/graphql/")
|
||||||
|
.subscription_endpoint("/api/graphql/ws")
|
||||||
|
.finish(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::post("/graphql", data = "<request>", format = "application/json")]
|
async fn start_ws(
|
||||||
async fn graphql_request(
|
ws: WebSocketUpgrade,
|
||||||
schema: &State<GraphqlSchema>,
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
request: GraphQLRequest,
|
State(AppState {
|
||||||
) -> GraphQLResponse {
|
connection_tracker, ..
|
||||||
request.execute(schema.inner()).await
|
}): State<AppState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
info!("intiating websocket connection for {addr}");
|
||||||
|
ws.on_upgrade(async move |socket| connection_tracker.lock().await.add_peer(socket, addr).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::main]
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct NotificationParams {
|
||||||
|
delay_ms: Option<u64>,
|
||||||
|
num_unprocessed: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_refresh_websocket_handler(
|
||||||
|
State(AppState {
|
||||||
|
nm,
|
||||||
|
pool,
|
||||||
|
connection_tracker,
|
||||||
|
..
|
||||||
|
}): State<AppState>,
|
||||||
|
params: Query<NotificationParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
info!("send_refresh_websocket_handler params {params:?}");
|
||||||
|
if let Some(delay_ms) = params.delay_ms {
|
||||||
|
let delay = Duration::from_millis(delay_ms);
|
||||||
|
info!("sleeping {delay:?}");
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
let limit = match params.num_unprocessed {
|
||||||
|
Some(0) => None,
|
||||||
|
Some(limit) => Some(limit),
|
||||||
|
None => Some(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ids = None;
|
||||||
|
match label_unprocessed(&nm, &pool, false, limit, "tag:unprocessed").await {
|
||||||
|
Ok(i) => ids = Some(i),
|
||||||
|
Err(err) => error!("Failed to label_unprocessed: {err:?}"),
|
||||||
|
};
|
||||||
|
connection_tracker
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.send_message_all(WebsocketMessage::RefreshMessages)
|
||||||
|
.await;
|
||||||
|
if let Some(ids) = ids {
|
||||||
|
format!("{ids:?}")
|
||||||
|
} else {
|
||||||
|
"refresh triggered".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn watch_new(
|
||||||
|
nm: Notmuch,
|
||||||
|
pool: PgPool,
|
||||||
|
conn_tracker: Arc<Mutex<ConnectionTracker>>,
|
||||||
|
poll_time: Duration,
|
||||||
|
) -> Result<(), async_graphql::Error> {
|
||||||
|
async fn watch_new_iteration(
|
||||||
|
nm: &Notmuch,
|
||||||
|
pool: &PgPool,
|
||||||
|
conn_tracker: Arc<Mutex<ConnectionTracker>>,
|
||||||
|
old_ids: &[String],
|
||||||
|
) -> Result<Vec<String>, async_graphql::Error> {
|
||||||
|
let ids = compute_catchup_ids(&nm, &pool, "is:unread").await?;
|
||||||
|
info!("old_ids: {} ids: {}", old_ids.len(), ids.len());
|
||||||
|
if old_ids != ids {
|
||||||
|
label_unprocessed(&nm, &pool, false, Some(100), "tag:unprocessed").await?;
|
||||||
|
conn_tracker
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.send_message_all(WebsocketMessage::RefreshMessages)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
let mut old_ids = Vec::new();
|
||||||
|
loop {
|
||||||
|
old_ids = match watch_new_iteration(&nm, &pool, conn_tracker.clone(), &old_ids).await {
|
||||||
|
Ok(old_ids) => old_ids,
|
||||||
|
Err(err) => {
|
||||||
|
error!("watch_new_iteration failed: {err:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::time::sleep(poll_time).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
nm: Notmuch,
|
||||||
|
pool: PgPool,
|
||||||
|
connection_tracker: Arc<Mutex<ConnectionTracker>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(short, long, default_value = "0.0.0.0:9345")]
|
||||||
|
addr: SocketAddr,
|
||||||
|
newsreader_database_url: String,
|
||||||
|
newsreader_tantivy_db_path: String,
|
||||||
|
slurp_cache_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let cli = Cli::parse();
|
||||||
let _guard = xtracing::init(env!("CARGO_BIN_NAME"))?;
|
let _guard = xtracing::init(env!("CARGO_BIN_NAME"))?;
|
||||||
build_info::build_info!(fn bi);
|
build_info::build_info!(fn bi);
|
||||||
info!("Build Info: {}", shared::build_version(bi));
|
info!("Build Info: {}", letterbox_shared::build_version(bi));
|
||||||
let allowed_origins = AllowedOrigins::all();
|
if !std::fs::exists(&cli.slurp_cache_path)? {
|
||||||
let cors = rocket_cors::CorsOptions {
|
info!("Creating slurp cache @ '{}'", &cli.slurp_cache_path);
|
||||||
allowed_origins,
|
std::fs::create_dir_all(&cli.slurp_cache_path)?;
|
||||||
allowed_methods: vec!["Get"]
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| FromStr::from_str(s).unwrap())
|
|
||||||
.collect(),
|
|
||||||
allowed_headers: AllowedHeaders::some(&["Authorization", "Accept"]),
|
|
||||||
allow_credentials: true,
|
|
||||||
..Default::default()
|
|
||||||
}
|
}
|
||||||
.to_cors()?;
|
let pool = PgPool::connect(&cli.newsreader_database_url).await?;
|
||||||
|
let nm = Notmuch::default();
|
||||||
let rkt = rocket::build()
|
|
||||||
.mount(
|
|
||||||
shared::urls::MOUNT_POINT,
|
|
||||||
routes![
|
|
||||||
original,
|
|
||||||
show_pretty,
|
|
||||||
show,
|
|
||||||
graphql_query,
|
|
||||||
graphql_request,
|
|
||||||
graphiql,
|
|
||||||
view_cid,
|
|
||||||
view_attachment,
|
|
||||||
download_attachment,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.attach(cors)
|
|
||||||
.attach(AdHoc::config::<Config>());
|
|
||||||
|
|
||||||
let config: Config = rkt.figment().extract()?;
|
|
||||||
if !std::fs::exists(&config.slurp_cache_path)? {
|
|
||||||
info!("Creating slurp cache @ '{}'", &config.slurp_cache_path);
|
|
||||||
std::fs::create_dir_all(&config.slurp_cache_path)?;
|
|
||||||
}
|
|
||||||
let pool = PgPool::connect(&config.newsreader_database_url).await?;
|
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
#[cfg(feature = "tantivy")]
|
#[cfg(feature = "tantivy")]
|
||||||
let tantivy_conn = TantivyConnection::new(&config.newsreader_tantivy_db_path)?;
|
let tantivy_conn = TantivyConnection::new(&cli.newsreader_tantivy_db_path)?;
|
||||||
|
|
||||||
let schema = Schema::build(QueryRoot, Mutation, EmptySubscription)
|
let cacher = FilesystemCacher::new(&cli.slurp_cache_path)?;
|
||||||
.data(Notmuch::default())
|
let schema = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
|
||||||
.data(config)
|
.data(nm.clone())
|
||||||
|
.data(cacher)
|
||||||
.data(pool.clone());
|
.data(pool.clone());
|
||||||
|
|
||||||
#[cfg(feature = "tantivy")]
|
|
||||||
let schema = schema.data(tantivy_conn);
|
|
||||||
|
|
||||||
let schema = schema.extension(extensions::Logger).finish();
|
let schema = schema.extension(extensions::Logger).finish();
|
||||||
|
|
||||||
let rkt = rkt.manage(schema).manage(pool).manage(Notmuch::default());
|
let connection_tracker = Arc::new(Mutex::new(ConnectionTracker::default()));
|
||||||
//.manage(Notmuch::with_config("../notmuch/testdata/notmuch.config"))
|
let ct = Arc::clone(&connection_tracker);
|
||||||
|
let poll_time = Duration::from_secs(60);
|
||||||
|
let _h = tokio::spawn(watch_new(nm.clone(), pool.clone(), ct, poll_time));
|
||||||
|
|
||||||
rkt.launch().await?;
|
let api_routes = Router::new()
|
||||||
|
.route(
|
||||||
|
"/download/attachment/{id}/{idx}/{*rest}",
|
||||||
|
get(download_attachment),
|
||||||
|
)
|
||||||
|
.route("/view/attachment/{id}/{idx}/{*rest}", get(view_attachment))
|
||||||
|
.route("/original/{id}", get(view_original))
|
||||||
|
.route("/cid/{id}/{cid}", get(view_cid))
|
||||||
|
.route("/ws", any(start_ws))
|
||||||
|
.route_service("/graphql/ws", GraphQLSubscription::new(schema.clone()))
|
||||||
|
.route(
|
||||||
|
"/graphql/",
|
||||||
|
get(graphiql).post_service(GraphQL::new(schema.clone())),
|
||||||
|
);
|
||||||
|
|
||||||
|
let notification_routes = Router::new()
|
||||||
|
.route("/mail", post(send_refresh_websocket_handler))
|
||||||
|
.route("/news", post(send_refresh_websocket_handler));
|
||||||
|
let app = Router::new()
|
||||||
|
.nest("/api", api_routes)
|
||||||
|
.nest("/notification", notification_routes)
|
||||||
|
.with_state(AppState {
|
||||||
|
nm,
|
||||||
|
pool,
|
||||||
|
connection_tracker,
|
||||||
|
})
|
||||||
|
.layer(
|
||||||
|
TraceLayer::new_for_http()
|
||||||
|
.make_span_with(DefaultMakeSpan::default().include_headers(true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(cli.addr).await.unwrap();
|
||||||
|
tracing::info!("listening on {}", listener.local_addr().unwrap());
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
39
server/src/bin/test-labeling.rs
Normal file
39
server/src/bin/test-labeling.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use letterbox_notmuch::Notmuch;
|
||||||
|
use letterbox_server::nm::label_unprocessed;
|
||||||
|
use sqlx::postgres::PgPool;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(short, long)]
|
||||||
|
newsreader_database_url: String,
|
||||||
|
#[arg(short, long, default_value = "10")]
|
||||||
|
/// Set to 0 to process all matches
|
||||||
|
messages_to_process: usize,
|
||||||
|
#[arg(short, long, default_value = "false")]
|
||||||
|
execute: bool,
|
||||||
|
/// Process messages matching this notmuch query
|
||||||
|
#[arg(short, long, default_value = "tag:unprocessed")]
|
||||||
|
query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let _guard = xtracing::init(env!("CARGO_BIN_NAME"))?;
|
||||||
|
build_info::build_info!(fn bi);
|
||||||
|
info!("Build Info: {}", letterbox_shared::build_version(bi));
|
||||||
|
let pool = PgPool::connect(&cli.newsreader_database_url).await?;
|
||||||
|
let nm = Notmuch::default();
|
||||||
|
let limit = if cli.messages_to_process > 0 {
|
||||||
|
Some(cli.messages_to_process)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
label_unprocessed(&nm, &pool, !cli.execute, limit, &cli.query).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
|||||||
pre {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background-color: var(--color-bg-secondary);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ use crate::TransformError;
|
|||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ServerError {
|
pub enum ServerError {
|
||||||
#[error("notmuch: {0}")]
|
#[error("notmuch: {0}")]
|
||||||
NotmuchError(#[from] notmuch::NotmuchError),
|
NotmuchError(#[from] letterbox_notmuch::NotmuchError),
|
||||||
#[error("flatten")]
|
#[error("flatten")]
|
||||||
FlattenError,
|
FlattenError,
|
||||||
#[error("mail parse error: {0}")]
|
#[error("mail parse error: {0}")]
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ use std::{fmt, str::FromStr};
|
|||||||
|
|
||||||
use async_graphql::{
|
use async_graphql::{
|
||||||
connection::{self, Connection, Edge, OpaqueCursor},
|
connection::{self, Connection, Edge, OpaqueCursor},
|
||||||
Context, EmptySubscription, Enum, Error, FieldResult, InputObject, Object, Schema,
|
futures_util::Stream,
|
||||||
SimpleObject, Union,
|
Context, Enum, Error, FieldResult, InputObject, Object, Schema, SimpleObject, Subscription,
|
||||||
|
Union,
|
||||||
};
|
};
|
||||||
|
use cacher::FilesystemCacher;
|
||||||
|
use futures::stream;
|
||||||
|
use letterbox_notmuch::Notmuch;
|
||||||
use log::info;
|
use log::info;
|
||||||
use notmuch::Notmuch;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::postgres::PgPool;
|
use sqlx::postgres::PgPool;
|
||||||
use tokio::join;
|
use tokio::join;
|
||||||
@@ -14,7 +17,7 @@ use tracing::instrument;
|
|||||||
|
|
||||||
#[cfg(feature = "tantivy")]
|
#[cfg(feature = "tantivy")]
|
||||||
use crate::tantivy::TantivyConnection;
|
use crate::tantivy::TantivyConnection;
|
||||||
use crate::{config::Config, newsreader, nm, Query};
|
use crate::{newsreader, nm, nm::label_unprocessed, Query};
|
||||||
|
|
||||||
/// # Number of seconds since the Epoch
|
/// # Number of seconds since the Epoch
|
||||||
pub type UnixTime = isize;
|
pub type UnixTime = isize;
|
||||||
@@ -94,6 +97,10 @@ pub struct Message {
|
|||||||
pub to: Vec<Email>,
|
pub to: Vec<Email>,
|
||||||
// All CC headers found in email
|
// All CC headers found in email
|
||||||
pub cc: Vec<Email>,
|
pub cc: Vec<Email>,
|
||||||
|
// X-Original-To header found in email
|
||||||
|
pub x_original_to: Option<Email>,
|
||||||
|
// Delivered-To header found in email
|
||||||
|
pub delivered_to: Option<Email>,
|
||||||
// First Subject header found in email
|
// First Subject header found in email
|
||||||
pub subject: Option<String>,
|
pub subject: Option<String>,
|
||||||
// Parsed Date header, if found and valid
|
// Parsed Date header, if found and valid
|
||||||
@@ -282,10 +289,9 @@ pub struct QueryRoot;
|
|||||||
impl QueryRoot {
|
impl QueryRoot {
|
||||||
async fn version<'ctx>(&self, _ctx: &Context<'ctx>) -> Result<String, Error> {
|
async fn version<'ctx>(&self, _ctx: &Context<'ctx>) -> Result<String, Error> {
|
||||||
build_info::build_info!(fn bi);
|
build_info::build_info!(fn bi);
|
||||||
Ok(shared::build_version(bi))
|
Ok(letterbox_shared::build_version(bi))
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(query=query))]
|
#[instrument(skip_all, fields(query=query, rid=request_id()))]
|
||||||
#[instrument(skip_all, fields(query=query, request_id=request_id()))]
|
|
||||||
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
|
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
let pool = ctx.data_unchecked::<PgPool>();
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
@@ -305,10 +311,20 @@ impl QueryRoot {
|
|||||||
info!("count {newsreader_query:?} newsreader count {newsreader_count} notmuch count {notmuch_count} tantivy count {tantivy_count} total {total}");
|
info!("count {newsreader_query:?} newsreader count {newsreader_count} notmuch count {notmuch_count} tantivy count {tantivy_count} total {total}");
|
||||||
Ok(total)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
#[instrument(skip_all, fields(query=query, rid=request_id()))]
|
||||||
|
async fn catchup<'ctx>(
|
||||||
|
&self,
|
||||||
|
ctx: &Context<'ctx>,
|
||||||
|
query: String,
|
||||||
|
) -> Result<Vec<String>, Error> {
|
||||||
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
|
compute_catchup_ids(nm, pool, &query).await
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: this function doesn't get parallelism, possibly because notmuch is sync and blocks,
|
// TODO: this function doesn't get parallelism, possibly because notmuch is sync and blocks,
|
||||||
// rewrite that with tokio::process:Command
|
// rewrite that with tokio::process:Command
|
||||||
#[instrument(skip_all, fields(query=query, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, rid=request_id()))]
|
||||||
async fn search<'ctx>(
|
async fn search<'ctx>(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Context<'ctx>,
|
ctx: &Context<'ctx>,
|
||||||
@@ -466,7 +482,7 @@ impl QueryRoot {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all, fields(request_id=request_id()))]
|
#[instrument(skip_all, fields(rid=request_id()))]
|
||||||
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {
|
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
let pool = ctx.data_unchecked::<PgPool>();
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
@@ -475,11 +491,11 @@ impl QueryRoot {
|
|||||||
tags.append(&mut nm::tags(nm, needs_unread)?);
|
tags.append(&mut nm::tags(nm, needs_unread)?);
|
||||||
Ok(tags)
|
Ok(tags)
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(thread_id=thread_id, request_id=request_id()))]
|
#[instrument(skip_all, fields(thread_id=thread_id, rid=request_id()))]
|
||||||
async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result<Thread, Error> {
|
async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result<Thread, Error> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
|
let cacher = ctx.data_unchecked::<FilesystemCacher>();
|
||||||
let pool = ctx.data_unchecked::<PgPool>();
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
let config = ctx.data_unchecked::<Config>();
|
|
||||||
let debug_content_tree = ctx
|
let debug_content_tree = ctx
|
||||||
.look_ahead()
|
.look_ahead()
|
||||||
.field("messages")
|
.field("messages")
|
||||||
@@ -487,7 +503,7 @@ impl QueryRoot {
|
|||||||
.field("contentTree")
|
.field("contentTree")
|
||||||
.exists();
|
.exists();
|
||||||
if newsreader::is_newsreader_thread(&thread_id) {
|
if newsreader::is_newsreader_thread(&thread_id) {
|
||||||
Ok(newsreader::thread(config, pool, thread_id).await?)
|
Ok(newsreader::thread(cacher, pool, thread_id).await?)
|
||||||
} else {
|
} else {
|
||||||
Ok(nm::thread(nm, pool, thread_id, debug_content_tree).await?)
|
Ok(nm::thread(nm, pool, thread_id, debug_content_tree).await?)
|
||||||
}
|
}
|
||||||
@@ -549,10 +565,10 @@ async fn tantivy_search(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Mutation;
|
pub struct MutationRoot;
|
||||||
#[Object]
|
#[Object]
|
||||||
impl Mutation {
|
impl MutationRoot {
|
||||||
#[instrument(skip_all, fields(query=query, unread=unread, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, unread=unread, rid=request_id()))]
|
||||||
async fn set_read_status<'ctx>(
|
async fn set_read_status<'ctx>(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Context<'ctx>,
|
ctx: &Context<'ctx>,
|
||||||
@@ -571,7 +587,7 @@ impl Mutation {
|
|||||||
nm::set_read_status(nm, &query, unread).await?;
|
nm::set_read_status(nm, &query, unread).await?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(query=query, tag=tag, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, tag=tag, rid=request_id()))]
|
||||||
async fn tag_add<'ctx>(
|
async fn tag_add<'ctx>(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Context<'ctx>,
|
ctx: &Context<'ctx>,
|
||||||
@@ -583,7 +599,7 @@ impl Mutation {
|
|||||||
nm.tag_add(&tag, &query)?;
|
nm.tag_add(&tag, &query)?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(query=query, tag=tag, request_id=request_id()))]
|
#[instrument(skip_all, fields(query=query, tag=tag, rid=request_id()))]
|
||||||
async fn tag_remove<'ctx>(
|
async fn tag_remove<'ctx>(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Context<'ctx>,
|
ctx: &Context<'ctx>,
|
||||||
@@ -606,14 +622,20 @@ impl Mutation {
|
|||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
#[instrument(skip_all, fields(request_id=request_id()))]
|
#[instrument(skip_all, fields(rid=request_id()))]
|
||||||
async fn refresh<'ctx>(&self, ctx: &Context<'ctx>) -> Result<bool, Error> {
|
async fn refresh<'ctx>(&self, ctx: &Context<'ctx>) -> Result<bool, Error> {
|
||||||
let nm = ctx.data_unchecked::<Notmuch>();
|
let nm = ctx.data_unchecked::<Notmuch>();
|
||||||
|
let cacher = ctx.data_unchecked::<FilesystemCacher>();
|
||||||
|
let pool = ctx.data_unchecked::<PgPool>();
|
||||||
info!("{}", String::from_utf8_lossy(&nm.new()?));
|
info!("{}", String::from_utf8_lossy(&nm.new()?));
|
||||||
|
newsreader::refresh(pool, cacher).await?;
|
||||||
|
|
||||||
|
// Process email labels
|
||||||
|
label_unprocessed(&nm, &pool, false, Some(10), "tag:unprocessed").await?;
|
||||||
|
|
||||||
#[cfg(feature = "tantivy")]
|
#[cfg(feature = "tantivy")]
|
||||||
{
|
{
|
||||||
let tantivy = ctx.data_unchecked::<TantivyConnection>();
|
let tantivy = ctx.data_unchecked::<TantivyConnection>();
|
||||||
let pool = ctx.data_unchecked::<PgPool>();
|
|
||||||
// TODO: parallelize
|
// TODO: parallelize
|
||||||
tantivy.refresh(pool).await?;
|
tantivy.refresh(pool).await?;
|
||||||
}
|
}
|
||||||
@@ -621,4 +643,51 @@ impl Mutation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>;
|
pub struct SubscriptionRoot;
|
||||||
|
#[Subscription]
|
||||||
|
impl SubscriptionRoot {
|
||||||
|
async fn values(&self, _ctx: &Context<'_>) -> Result<impl Stream<Item = usize>, Error> {
|
||||||
|
Ok(stream::iter(0..10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type GraphqlSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
|
||||||
|
|
||||||
|
#[instrument(skip_all, fields(query=query))]
|
||||||
|
pub async fn compute_catchup_ids(
|
||||||
|
nm: &Notmuch,
|
||||||
|
pool: &PgPool,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<String>, Error> {
|
||||||
|
let query: Query = query.parse()?;
|
||||||
|
// TODO: implement optimized versions of fetching just IDs
|
||||||
|
let newsreader_fut = newsreader_search(pool, None, None, None, None, &query);
|
||||||
|
let notmuch_fut = notmuch_search(nm, None, None, None, None, &query);
|
||||||
|
let (newsreader_results, notmuch_results) = join!(newsreader_fut, notmuch_fut);
|
||||||
|
|
||||||
|
let newsreader_results = newsreader_results?;
|
||||||
|
let notmuch_results = notmuch_results?;
|
||||||
|
info!(
|
||||||
|
"newsreader_results ({}) notmuch_results ({})",
|
||||||
|
newsreader_results.len(),
|
||||||
|
notmuch_results.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut results: Vec<_> = newsreader_results
|
||||||
|
.into_iter()
|
||||||
|
.chain(notmuch_results)
|
||||||
|
.collect();
|
||||||
|
// The leading '-' is to reverse sort
|
||||||
|
results.sort_by_key(|item| match item {
|
||||||
|
ThreadSummaryCursor::Newsreader(_, ts) => -ts.timestamp,
|
||||||
|
ThreadSummaryCursor::Notmuch(_, ts) => -ts.timestamp,
|
||||||
|
});
|
||||||
|
let ids = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| match r {
|
||||||
|
ThreadSummaryCursor::Newsreader(_, ts) => ts.thread,
|
||||||
|
ThreadSummaryCursor::Notmuch(_, ts) => ts.thread,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,32 +1,40 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod mail;
|
|
||||||
pub mod newsreader;
|
pub mod newsreader;
|
||||||
pub mod nm;
|
pub mod nm;
|
||||||
|
pub mod ws;
|
||||||
|
|
||||||
#[cfg(feature = "tantivy")]
|
#[cfg(feature = "tantivy")]
|
||||||
pub mod tantivy;
|
pub mod tantivy;
|
||||||
|
|
||||||
use std::{collections::HashMap, convert::Infallible, fmt, str::FromStr, sync::Arc};
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
convert::Infallible,
|
||||||
|
fmt,
|
||||||
|
str::FromStr,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use cacher::{Cacher, FilesystemCacher};
|
use cacher::{Cacher, FilesystemCacher};
|
||||||
use css_inline::{CSSInliner, InlineError, InlineOptions};
|
use css_inline::{CSSInliner, InlineError, InlineOptions};
|
||||||
|
pub use error::ServerError;
|
||||||
use linkify::{LinkFinder, LinkKind};
|
use linkify::{LinkFinder, LinkKind};
|
||||||
use log::{error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use lol_html::{
|
use lol_html::{
|
||||||
element, errors::RewritingError, html_content::ContentType, rewrite_str, text,
|
element, errors::RewritingError, html_content::ContentType, rewrite_str, text,
|
||||||
RewriteStrSettings,
|
RewriteStrSettings,
|
||||||
};
|
};
|
||||||
use maplit::{hashmap, hashset};
|
use maplit::{hashmap, hashset};
|
||||||
|
use regex::Regex;
|
||||||
|
use reqwest::StatusCode;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use sqlx::types::time::PrimitiveDateTime;
|
use sqlx::types::time::PrimitiveDateTime;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ServerError,
|
|
||||||
graphql::{Corpus, ThreadSummary},
|
graphql::{Corpus, ThreadSummary},
|
||||||
newsreader::is_newsreader_thread,
|
newsreader::is_newsreader_thread,
|
||||||
nm::is_notmuch_thread_or_id,
|
nm::is_notmuch_thread_or_id,
|
||||||
@@ -58,6 +66,8 @@ pub enum TransformError {
|
|||||||
ReqwestError(#[from] reqwest::Error),
|
ReqwestError(#[from] reqwest::Error),
|
||||||
#[error("failed to parse HTML: {0}")]
|
#[error("failed to parse HTML: {0}")]
|
||||||
HtmlParsingError(String),
|
HtmlParsingError(String),
|
||||||
|
#[error("got a retryable error code {0} for {1}")]
|
||||||
|
RetryableHttpStatusError(StatusCode, String),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SanitizeHtml<'a> {
|
struct SanitizeHtml<'a> {
|
||||||
@@ -88,70 +98,49 @@ struct StripHtml;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Transformer for StripHtml {
|
impl Transformer for StripHtml {
|
||||||
fn should_run(&self, _: &Option<Url>, html: &str) -> bool {
|
fn should_run(&self, link: &Option<Url>, html: &str) -> bool {
|
||||||
|
debug!("StripHtml should_run {link:?} {}", html.contains("<"));
|
||||||
// Lame test
|
// Lame test
|
||||||
html.contains("<")
|
html.contains("<")
|
||||||
}
|
}
|
||||||
async fn transform(&self, _: &Option<Url>, html: &str) -> Result<String, TransformError> {
|
async fn transform(&self, link: &Option<Url>, html: &str) -> Result<String, TransformError> {
|
||||||
|
debug!("StripHtml {link:?}");
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let element_content_handlers = vec![text!("*", |t| {
|
let element_content_handlers = vec![
|
||||||
text += t.as_str();
|
element!("style", |el| {
|
||||||
Ok(())
|
el.remove();
|
||||||
})];
|
Ok(())
|
||||||
let _ = rewrite_str(
|
}),
|
||||||
|
element!("script", |el| {
|
||||||
|
el.remove();
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let html = rewrite_str(
|
||||||
html,
|
html,
|
||||||
RewriteStrSettings {
|
RewriteStrSettings {
|
||||||
element_content_handlers,
|
element_content_handlers,
|
||||||
..RewriteStrSettings::default()
|
..RewriteStrSettings::default()
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
let element_content_handlers = vec![text!("*", |t| {
|
||||||
|
text += t.as_str();
|
||||||
|
Ok(())
|
||||||
|
})];
|
||||||
|
let _ = rewrite_str(
|
||||||
|
&html,
|
||||||
|
RewriteStrSettings {
|
||||||
|
element_content_handlers,
|
||||||
|
..RewriteStrSettings::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let re = Regex::new(r"\s+").expect("failed to parse regex");
|
||||||
|
let text = re.replace_all(&text, " ").to_string();
|
||||||
|
|
||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InlineRemoteStyle<'a> {
|
|
||||||
base_url: &'a Option<Url>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<'a> Transformer for InlineRemoteStyle<'a> {
|
|
||||||
async fn transform(&self, _: &Option<Url>, html: &str) -> Result<String, TransformError> {
|
|
||||||
let css = concat!(
|
|
||||||
"/* chrome-default.css */\n",
|
|
||||||
include_str!("chrome-default.css"),
|
|
||||||
"\n/* mvp.css */\n",
|
|
||||||
include_str!("mvp.css"),
|
|
||||||
"\n/* Xinu Specific overrides */\n",
|
|
||||||
include_str!("custom.css"),
|
|
||||||
);
|
|
||||||
let inline_opts = InlineOptions {
|
|
||||||
//inline_style_tags: true,
|
|
||||||
//keep_style_tags: false,
|
|
||||||
//keep_link_tags: true,
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
//load_remote_stylesheets: true,
|
|
||||||
//preallocate_node_capacity: 32,
|
|
||||||
..InlineOptions::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
//info!("HTML:\n{html}");
|
|
||||||
info!("base_url: {:#?}", self.base_url);
|
|
||||||
Ok(
|
|
||||||
match CSSInliner::options()
|
|
||||||
.base_url(self.base_url.clone())
|
|
||||||
.build()
|
|
||||||
.inline(&html)
|
|
||||||
{
|
|
||||||
Ok(inlined_html) => inlined_html,
|
|
||||||
Err(err) => {
|
|
||||||
error!("failed to inline remote CSS: {err}");
|
|
||||||
html.to_string()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct InlineStyle;
|
struct InlineStyle;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -160,10 +149,10 @@ impl Transformer for InlineStyle {
|
|||||||
let css = concat!(
|
let css = concat!(
|
||||||
"/* chrome-default.css */\n",
|
"/* chrome-default.css */\n",
|
||||||
include_str!("chrome-default.css"),
|
include_str!("chrome-default.css"),
|
||||||
"\n/* mvp.css */\n",
|
//"\n/* mvp.css */\n",
|
||||||
include_str!("mvp.css"),
|
//include_str!("mvp.css"),
|
||||||
"\n/* Xinu Specific overrides */\n",
|
//"\n/* Xinu Specific overrides */\n",
|
||||||
include_str!("custom.css"),
|
//include_str!("custom.css"),
|
||||||
);
|
);
|
||||||
let inline_opts = InlineOptions {
|
let inline_opts = InlineOptions {
|
||||||
inline_style_tags: true,
|
inline_style_tags: true,
|
||||||
@@ -269,13 +258,13 @@ impl Transformer for AddOutlink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SlurpContents {
|
struct SlurpContents<'c> {
|
||||||
cacher: Arc<Mutex<FilesystemCacher>>,
|
cacher: &'c FilesystemCacher,
|
||||||
inline_css: bool,
|
inline_css: bool,
|
||||||
site_selectors: HashMap<String, Vec<Selector>>,
|
site_selectors: HashMap<String, Vec<Selector>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SlurpContents {
|
impl<'c> SlurpContents<'c> {
|
||||||
fn get_selectors(&self, link: &Url) -> Option<&[Selector]> {
|
fn get_selectors(&self, link: &Url) -> Option<&[Selector]> {
|
||||||
for (host, selector) in self.site_selectors.iter() {
|
for (host, selector) in self.site_selectors.iter() {
|
||||||
if link.host_str().map(|h| h.contains(host)).unwrap_or(false) {
|
if link.host_str().map(|h| h.contains(host)).unwrap_or(false) {
|
||||||
@@ -287,36 +276,82 @@ impl SlurpContents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Transformer for SlurpContents {
|
impl<'c> Transformer for SlurpContents<'c> {
|
||||||
fn should_run(&self, link: &Option<Url>, _: &str) -> bool {
|
fn should_run(&self, link: &Option<Url>, html: &str) -> bool {
|
||||||
|
debug!("SlurpContents should_run {link:?}");
|
||||||
|
let mut will_slurp = false;
|
||||||
if let Some(link) = link {
|
if let Some(link) = link {
|
||||||
return self.get_selectors(link).is_some();
|
will_slurp = self.get_selectors(link).is_some();
|
||||||
}
|
}
|
||||||
false
|
if !will_slurp && self.inline_css {
|
||||||
|
return InlineStyle {}.should_run(link, html);
|
||||||
|
}
|
||||||
|
will_slurp
|
||||||
}
|
}
|
||||||
async fn transform(&self, link: &Option<Url>, html: &str) -> Result<String, TransformError> {
|
async fn transform(&self, link: &Option<Url>, html: &str) -> Result<String, TransformError> {
|
||||||
|
debug!("SlurpContents {link:?}");
|
||||||
|
let retryable_status: HashSet<StatusCode> = vec![
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
if let Some(test_link) = link {
|
||||||
|
// If SlurpContents is configured for inline CSS, but no
|
||||||
|
// configuration found for this site, use the local InlineStyle
|
||||||
|
// transform.
|
||||||
|
if self.inline_css && self.get_selectors(test_link).is_none() {
|
||||||
|
debug!("local inline CSS for {link:?}");
|
||||||
|
return InlineStyle {}.transform(link, html).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(link) = link else {
|
let Some(link) = link else {
|
||||||
return Ok(html.to_string());
|
return Ok(html.to_string());
|
||||||
};
|
};
|
||||||
let Some(selectors) = self.get_selectors(&link) else {
|
let Some(selectors) = self.get_selectors(&link) else {
|
||||||
return Ok(html.to_string());
|
return Ok(html.to_string());
|
||||||
};
|
};
|
||||||
let cacher = self.cacher.lock().await;
|
let cacher = self.cacher;
|
||||||
let body = if let Some(body) = cacher.get(link.as_str()) {
|
let body = if let Some(body) = cacher.get(link.as_str()) {
|
||||||
info!("cache hit for {link}");
|
|
||||||
String::from_utf8_lossy(&body).to_string()
|
String::from_utf8_lossy(&body).to_string()
|
||||||
} else {
|
} else {
|
||||||
let body = reqwest::get(link.as_str()).await?.text().await?;
|
let resp = reqwest::get(link.as_str()).await?;
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_server_error() {
|
||||||
|
error!("status error for {link}: {status}");
|
||||||
|
return Ok(html.to_string());
|
||||||
|
}
|
||||||
|
if retryable_status.contains(&status) {
|
||||||
|
error!("retryable error for {link}: {status}");
|
||||||
|
return Ok(html.to_string());
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
error!("unsuccessful for {link}: {status}");
|
||||||
|
return Ok(html.to_string());
|
||||||
|
}
|
||||||
|
let body = resp.text().await?;
|
||||||
cacher.set(link.as_str(), body.as_bytes());
|
cacher.set(link.as_str(), body.as_bytes());
|
||||||
body
|
body
|
||||||
};
|
};
|
||||||
let body = Arc::new(body);
|
let body = Arc::new(body);
|
||||||
let base_url = Some(link.clone());
|
let base_url = Some(link.clone());
|
||||||
let body = if self.inline_css {
|
let body = if self.inline_css {
|
||||||
|
debug!("inlining CSS for {link}");
|
||||||
let inner_body = Arc::clone(&body);
|
let inner_body = Arc::clone(&body);
|
||||||
let res = tokio::task::spawn_blocking(move || {
|
let res = tokio::task::spawn_blocking(move || {
|
||||||
|
let css = concat!(
|
||||||
|
"/* chrome-default.css */\n",
|
||||||
|
include_str!("chrome-default.css"),
|
||||||
|
"\n/* vars.css */\n",
|
||||||
|
include_str!("../static/vars.css"),
|
||||||
|
//"\n/* Xinu Specific overrides */\n",
|
||||||
|
//include_str!("custom.css"),
|
||||||
|
);
|
||||||
let res = CSSInliner::options()
|
let res = CSSInliner::options()
|
||||||
.base_url(base_url)
|
.base_url(base_url)
|
||||||
|
.extra_css(Some(std::borrow::Cow::Borrowed(css)))
|
||||||
.build()
|
.build()
|
||||||
.inline(&inner_body);
|
.inline(&inner_body);
|
||||||
|
|
||||||
@@ -337,6 +372,7 @@ impl Transformer for SlurpContents {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
debug!("using body as-is for {link:?}");
|
||||||
Arc::into_inner(body).expect("failed to take body out of Arc")
|
Arc::into_inner(body).expect("failed to take body out of Arc")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,6 +442,34 @@ pub fn sanitize_html(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut element_content_handlers = vec![
|
let mut element_content_handlers = vec![
|
||||||
|
// Remove width and height attributes on elements
|
||||||
|
element!("[width],[height]", |el| {
|
||||||
|
el.remove_attribute("width");
|
||||||
|
el.remove_attribute("height");
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
// Remove width and height values from inline styles
|
||||||
|
element!("[style]", |el| {
|
||||||
|
let style = el.get_attribute("style").unwrap();
|
||||||
|
let style = style
|
||||||
|
.split(";")
|
||||||
|
.filter(|s| {
|
||||||
|
let Some((k, _)) = s.split_once(':') else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
match k {
|
||||||
|
"width" | "max-width" | "min-width" | "height" | "max-height"
|
||||||
|
| "min-height" => false,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
if let Err(e) = el.set_attribute("style", &style) {
|
||||||
|
error!("Failed to set style attribute: {e}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
// Open links in new tab
|
// Open links in new tab
|
||||||
element!("a[href]", |el| {
|
element!("a[href]", |el| {
|
||||||
el.set_attribute("target", "_blank").unwrap();
|
el.set_attribute("target", "_blank").unwrap();
|
||||||
@@ -650,7 +714,7 @@ fn compute_offset_limit(
|
|||||||
first: Option<i32>,
|
first: Option<i32>,
|
||||||
last: Option<i32>,
|
last: Option<i32>,
|
||||||
) -> (i32, i32) {
|
) -> (i32, i32) {
|
||||||
let default_page_size = 100;
|
let default_page_size = 10000;
|
||||||
match (after, before, first, last) {
|
match (after, before, first, last) {
|
||||||
// Reasonable defaults
|
// Reasonable defaults
|
||||||
(None, None, None, None) => (0, default_page_size),
|
(None, None, None, None) => (0, default_page_size),
|
||||||
@@ -741,7 +805,19 @@ impl Query {
|
|||||||
for uid in &self.uids {
|
for uid in &self.uids {
|
||||||
parts.push(uid.clone());
|
parts.push(uid.clone());
|
||||||
}
|
}
|
||||||
parts.extend(self.remainder.clone());
|
for r in &self.remainder {
|
||||||
|
// Rewrite "to:" to include ExtraTo:. ExtraTo: is configured in
|
||||||
|
// notmuch-config to index Delivered-To and X-Original-To headers.
|
||||||
|
if r.starts_with("to:") {
|
||||||
|
parts.push("(".to_string());
|
||||||
|
parts.push(r.to_string());
|
||||||
|
parts.push("OR".to_string());
|
||||||
|
parts.push(r.replace("to:", "ExtraTo:"));
|
||||||
|
parts.push(")".to_string());
|
||||||
|
} else {
|
||||||
|
parts.push(r.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
parts.join(" ")
|
parts.join(" ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,7 +837,17 @@ impl FromStr for Query {
|
|||||||
if word == "is:unread" {
|
if word == "is:unread" {
|
||||||
unread_only = true
|
unread_only = true
|
||||||
} else if word.starts_with("tag:") {
|
} else if word.starts_with("tag:") {
|
||||||
tags.push(word["tag:".len()..].to_string());
|
let t = &word["tag:".len()..];
|
||||||
|
// Per-address emails are faked as `tag:@<domain>/<username>`, rewrite to `to:` form
|
||||||
|
if t.starts_with('@') && t.contains('.') {
|
||||||
|
let t = match t.split_once('/') {
|
||||||
|
None => format!("to:{t}"),
|
||||||
|
Some((domain, user)) => format!("to:{user}{domain}"),
|
||||||
|
};
|
||||||
|
remainder.push(t);
|
||||||
|
} else {
|
||||||
|
tags.push(t.to_string());
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
} else if word.starts_with("tag:") {
|
} else if word.starts_with("tag:") {
|
||||||
@@ -856,3 +942,21 @@ async fn clean_title(title: &str) -> Result<String, ServerError> {
|
|||||||
}
|
}
|
||||||
Ok(title)
|
Ok(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{SanitizeHtml, Transformer};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn strip_sizes() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let ss = SanitizeHtml {
|
||||||
|
cid_prefix: "",
|
||||||
|
base_url: &None,
|
||||||
|
};
|
||||||
|
let input = r#"<p width=16 height=16 style="color:blue;width:16px;height:16px;">This el has width and height attributes and inline styles</p>"#;
|
||||||
|
let want = r#"<p style="color:blue;">This el has width and height attributes and inline styles</p>"#;
|
||||||
|
let got = ss.transform(&None, input).await?;
|
||||||
|
assert_eq!(got, want);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
use std::{fs::File, io::Read};
|
|
||||||
|
|
||||||
use mailparse::{
|
|
||||||
addrparse_header, dateparse, parse_mail, MailHeaderMap, MailParseError, ParsedMail,
|
|
||||||
};
|
|
||||||
use sqlx::postgres::PgPool;
|
|
||||||
use thiserror::Error;
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum MailError {
|
|
||||||
#[error("missing from header")]
|
|
||||||
MissingFrom,
|
|
||||||
#[error("missing from header display name")]
|
|
||||||
MissingFromDisplayName,
|
|
||||||
#[error("missing subject header")]
|
|
||||||
MissingSubject,
|
|
||||||
#[error("missing html part")]
|
|
||||||
MissingHtmlPart,
|
|
||||||
#[error("missing message ID")]
|
|
||||||
MissingMessageId,
|
|
||||||
#[error("missing date")]
|
|
||||||
MissingDate,
|
|
||||||
#[error("DB error {0}")]
|
|
||||||
SqlxError(#[from] sqlx::Error),
|
|
||||||
#[error("IO error {0}")]
|
|
||||||
IOError(#[from] std::io::Error),
|
|
||||||
#[error("mail parse error {0}")]
|
|
||||||
MailParseError(#[from] MailParseError),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_mail_to_db(pool: &PgPool, path: &str) -> Result<(), MailError> {
|
|
||||||
let mut file = File::open(path)?;
|
|
||||||
let mut buffer = Vec::new();
|
|
||||||
file.read_to_end(&mut buffer)?;
|
|
||||||
let m = parse_mail(&buffer)?;
|
|
||||||
|
|
||||||
let subject = m
|
|
||||||
.headers
|
|
||||||
.get_first_value("subject")
|
|
||||||
.ok_or(MailError::MissingSubject)?;
|
|
||||||
|
|
||||||
let from = addrparse_header(
|
|
||||||
m.headers
|
|
||||||
.get_first_header("from")
|
|
||||||
.ok_or(MailError::MissingFrom)?,
|
|
||||||
)?;
|
|
||||||
let from = from.extract_single_info().ok_or(MailError::MissingFrom)?;
|
|
||||||
let name = from.display_name.ok_or(MailError::MissingFromDisplayName)?;
|
|
||||||
let slug = name.to_lowercase().replace(' ', "-");
|
|
||||||
let url = from.addr;
|
|
||||||
let message_id = m
|
|
||||||
.headers
|
|
||||||
.get_first_value("Message-ID")
|
|
||||||
.ok_or(MailError::MissingMessageId)?;
|
|
||||||
let uid = &message_id;
|
|
||||||
let feed_id = find_feed(&pool, &name, &slug, &url).await?;
|
|
||||||
let date = dateparse(
|
|
||||||
&m.headers
|
|
||||||
.get_first_value("Date")
|
|
||||||
.ok_or(MailError::MissingDate)?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
println!("Feed: {feed_id} Subject: {}", subject);
|
|
||||||
|
|
||||||
if let Some(_m) = first_html(&m) {
|
|
||||||
info!("add email {slug} {subject} {message_id} {date} {uid} {url}");
|
|
||||||
} else {
|
|
||||||
return Err(MailError::MissingHtmlPart.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn first_html<'m>(m: &'m ParsedMail<'m>) -> Option<&'m ParsedMail<'m>> {
|
|
||||||
for ele in m.parts() {
|
|
||||||
if ele.ctype.mimetype == "text/html" {
|
|
||||||
return Some(ele);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
async fn find_feed(pool: &PgPool, name: &str, slug: &str, url: &str) -> Result<i32, MailError> {
|
|
||||||
match sqlx::query!(
|
|
||||||
r#"
|
|
||||||
SELECT id
|
|
||||||
FROM feed
|
|
||||||
WHERE slug = $1
|
|
||||||
"#,
|
|
||||||
slug
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Err(sqlx::Error::RowNotFound) => {
|
|
||||||
let rec = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO feed ( name, slug, url, homepage, selector )
|
|
||||||
VALUES ( $1, $2, $3, '', '' )
|
|
||||||
RETURNING id
|
|
||||||
"#,
|
|
||||||
name,
|
|
||||||
slug,
|
|
||||||
url
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
return Ok(rec.id);
|
|
||||||
}
|
|
||||||
Ok(rec) => return Ok(rec.id),
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
use std::sync::Arc;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use cacher::FilesystemCacher;
|
use cacher::FilesystemCacher;
|
||||||
use log::info;
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
|
use letterbox_shared::compute_color;
|
||||||
|
use log::{error, info};
|
||||||
use maplit::hashmap;
|
use maplit::hashmap;
|
||||||
use scraper::Selector;
|
use scraper::Selector;
|
||||||
use shared::compute_color;
|
|
||||||
use sqlx::postgres::PgPool;
|
use sqlx::postgres::PgPool;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
clean_title, compute_offset_limit,
|
clean_title, compute_offset_limit,
|
||||||
config::Config,
|
|
||||||
error::ServerError,
|
error::ServerError,
|
||||||
graphql::{Corpus, NewsPost, Tag, Thread, ThreadSummary},
|
graphql::{Corpus, NewsPost, Tag, Thread, ThreadSummary},
|
||||||
thread_summary_from_row, AddOutlink, EscapeHtml, FrameImages, InlineRemoteStyle, Query,
|
thread_summary_from_row, AddOutlink, FrameImages, Query, SanitizeHtml, SlurpContents,
|
||||||
SanitizeHtml, SlurpContents, ThreadSummaryRecord, Transformer, NEWSREADER_TAG_PREFIX,
|
StripHtml, ThreadSummaryRecord, Transformer, NEWSREADER_TAG_PREFIX, NEWSREADER_THREAD_PREFIX,
|
||||||
NEWSREADER_THREAD_PREFIX,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn is_newsreader_query(query: &Query) -> bool {
|
pub fn is_newsreader_query(query: &Query) -> bool {
|
||||||
@@ -174,7 +172,7 @@ pub async fn tags(pool: &PgPool, _needs_unread: bool) -> Result<Vec<Tag>, Server
|
|||||||
|
|
||||||
#[instrument(name = "newsreader::thread", skip_all, fields(thread_id=%thread_id))]
|
#[instrument(name = "newsreader::thread", skip_all, fields(thread_id=%thread_id))]
|
||||||
pub async fn thread(
|
pub async fn thread(
|
||||||
config: &Config,
|
cacher: &FilesystemCacher,
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
thread_id: String,
|
thread_id: String,
|
||||||
) -> Result<Thread, ServerError> {
|
) -> Result<Thread, ServerError> {
|
||||||
@@ -191,65 +189,12 @@ pub async fn thread(
|
|||||||
let site = r.name.unwrap_or("NO SITE".to_string());
|
let site = r.name.unwrap_or("NO SITE".to_string());
|
||||||
// TODO: remove the various places that have this as an Option
|
// TODO: remove the various places that have this as an Option
|
||||||
let link = Some(Url::parse(&r.link)?);
|
let link = Some(Url::parse(&r.link)?);
|
||||||
let mut body = r.summary.unwrap_or("NO SUMMARY".to_string());
|
let mut body = r.clean_summary.unwrap_or("NO SUMMARY".to_string());
|
||||||
let cacher = Arc::new(Mutex::new(FilesystemCacher::new(&config.slurp_cache_path)?));
|
let body_transformers: Vec<Box<dyn Transformer>> = vec![
|
||||||
let body_tranformers: Vec<Box<dyn Transformer>> = vec![
|
|
||||||
Box::new(SlurpContents {
|
Box::new(SlurpContents {
|
||||||
cacher,
|
cacher,
|
||||||
// TODO: make this true when bulma is finally removed
|
inline_css: true,
|
||||||
inline_css: false,
|
site_selectors: slurp_contents_selectors(),
|
||||||
site_selectors: hashmap![
|
|
||||||
"atmeta.com".to_string() => vec![
|
|
||||||
Selector::parse("div.entry-content").unwrap(),
|
|
||||||
],
|
|
||||||
"blog.prusa3d.com".to_string() => vec![
|
|
||||||
Selector::parse("article.content .post-block").unwrap(),
|
|
||||||
],
|
|
||||||
"blog.cloudflare.com".to_string() => vec![
|
|
||||||
Selector::parse(".author-lists .author-name-tooltip").unwrap(),
|
|
||||||
Selector::parse(".post-full-content").unwrap()
|
|
||||||
],
|
|
||||||
"blog.zsa.io".to_string() => vec![
|
|
||||||
Selector::parse("section.blog-article").unwrap(),
|
|
||||||
],
|
|
||||||
"engineering.fb.com".to_string() => vec![
|
|
||||||
Selector::parse("article").unwrap(),
|
|
||||||
],
|
|
||||||
"grafana.com".to_string() => vec![
|
|
||||||
Selector::parse(".blog-content").unwrap(),
|
|
||||||
],
|
|
||||||
"hackaday.com".to_string() => vec![
|
|
||||||
Selector::parse("div.entry-featured-image").unwrap(),
|
|
||||||
Selector::parse("div.entry-content").unwrap()
|
|
||||||
],
|
|
||||||
"ingowald.blog".to_string() => vec![
|
|
||||||
Selector::parse("article").unwrap(),
|
|
||||||
],
|
|
||||||
"jvns.ca".to_string() => vec![
|
|
||||||
Selector::parse("article").unwrap(),
|
|
||||||
],
|
|
||||||
"mitchellh.com".to_string() => vec![Selector::parse("div.w-full").unwrap()],
|
|
||||||
"natwelch.com".to_string() => vec![
|
|
||||||
Selector::parse("article div.prose").unwrap(),
|
|
||||||
],
|
|
||||||
"rustacean-station.org".to_string() => vec![
|
|
||||||
Selector::parse("article").unwrap(),
|
|
||||||
],
|
|
||||||
"slashdot.org".to_string() => vec![
|
|
||||||
Selector::parse("span.story-byline").unwrap(),
|
|
||||||
Selector::parse("div.p").unwrap(),
|
|
||||||
],
|
|
||||||
"trofi.github.io".to_string() => vec![
|
|
||||||
Selector::parse("#content").unwrap(),
|
|
||||||
],
|
|
||||||
"www.redox-os.org".to_string() => vec![
|
|
||||||
Selector::parse("div.content").unwrap(),
|
|
||||||
],
|
|
||||||
"www.smbc-comics.com".to_string() => vec![
|
|
||||||
Selector::parse("img#cc-comic").unwrap(),
|
|
||||||
Selector::parse("div#aftercomic img").unwrap(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
Box::new(FrameImages),
|
Box::new(FrameImages),
|
||||||
Box::new(AddOutlink),
|
Box::new(AddOutlink),
|
||||||
@@ -260,7 +205,7 @@ pub async fn thread(
|
|||||||
base_url: &link,
|
base_url: &link,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
for t in body_tranformers.iter() {
|
for t in body_transformers.iter() {
|
||||||
if t.should_run(&link, &body) {
|
if t.should_run(&link, &body) {
|
||||||
body = t.transform(&link, &body).await?;
|
body = t.transform(&link, &body).await?;
|
||||||
}
|
}
|
||||||
@@ -308,3 +253,132 @@ pub async fn set_read_status<'ctx>(
|
|||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
#[instrument(name = "newsreader::refresh", skip_all)]
|
||||||
|
pub async fn refresh<'ctx>(pool: &PgPool, cacher: &FilesystemCacher) -> Result<bool, ServerError> {
|
||||||
|
async fn update_search_summary(
|
||||||
|
pool: &PgPool,
|
||||||
|
cacher: &FilesystemCacher,
|
||||||
|
link: String,
|
||||||
|
body: String,
|
||||||
|
id: i32,
|
||||||
|
) -> Result<(), ServerError> {
|
||||||
|
let slurp_contents = SlurpContents {
|
||||||
|
cacher,
|
||||||
|
inline_css: true,
|
||||||
|
site_selectors: slurp_contents_selectors(),
|
||||||
|
};
|
||||||
|
let strip_html = StripHtml;
|
||||||
|
|
||||||
|
info!("adding {link} to search index");
|
||||||
|
let mut body = body;
|
||||||
|
if let Ok(link) = Url::parse(&link) {
|
||||||
|
let link = Some(link);
|
||||||
|
if slurp_contents.should_run(&link, &body) {
|
||||||
|
body = slurp_contents.transform(&link, &body).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("failed to parse link: {}", link);
|
||||||
|
}
|
||||||
|
body = strip_html.transform(&None, &body).await?;
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE post SET search_summary = $1 WHERE id = $2",
|
||||||
|
body,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut unordered: FuturesUnordered<_> = sqlx::query_file!("sql/need-search-summary.sql",)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|r| {
|
||||||
|
let Some(body) = r.clean_summary else {
|
||||||
|
error!("clean_summary missing for {}", r.link);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let id = r.id;
|
||||||
|
Some(update_search_summary(pool, cacher, r.link, body, id))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
while let Some(res) = unordered.next().await {
|
||||||
|
//let res = res;
|
||||||
|
match res {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(err) => {
|
||||||
|
info!("failed refresh {err:?}");
|
||||||
|
// TODO:
|
||||||
|
//fd.error = Some(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slurp_contents_selectors() -> HashMap<String, Vec<Selector>> {
|
||||||
|
hashmap![
|
||||||
|
"atmeta.com".to_string() => vec![
|
||||||
|
Selector::parse("div.entry-content").unwrap(),
|
||||||
|
],
|
||||||
|
"blog.prusa3d.com".to_string() => vec![
|
||||||
|
Selector::parse("article.content .post-block").unwrap(),
|
||||||
|
],
|
||||||
|
"blog.cloudflare.com".to_string() => vec![
|
||||||
|
Selector::parse(".author-lists .author-name-tooltip").unwrap(),
|
||||||
|
Selector::parse(".post-full-content").unwrap()
|
||||||
|
],
|
||||||
|
"blog.zsa.io".to_string() => vec![
|
||||||
|
Selector::parse("section.blog-article").unwrap(),
|
||||||
|
],
|
||||||
|
"engineering.fb.com".to_string() => vec![
|
||||||
|
Selector::parse("article").unwrap(),
|
||||||
|
],
|
||||||
|
"grafana.com".to_string() => vec![
|
||||||
|
Selector::parse(".blog-content").unwrap(),
|
||||||
|
],
|
||||||
|
"hackaday.com".to_string() => vec![
|
||||||
|
Selector::parse("div.entry-featured-image").unwrap(),
|
||||||
|
Selector::parse("div.entry-content").unwrap()
|
||||||
|
],
|
||||||
|
"ingowald.blog".to_string() => vec![
|
||||||
|
Selector::parse("article").unwrap(),
|
||||||
|
],
|
||||||
|
"jvns.ca".to_string() => vec![
|
||||||
|
Selector::parse("article").unwrap(),
|
||||||
|
],
|
||||||
|
"mitchellh.com".to_string() => vec![Selector::parse("div.w-full").unwrap()],
|
||||||
|
"natwelch.com".to_string() => vec![
|
||||||
|
Selector::parse("article div.prose").unwrap(),
|
||||||
|
],
|
||||||
|
"rustacean-station.org".to_string() => vec![
|
||||||
|
Selector::parse("article").unwrap(),
|
||||||
|
],
|
||||||
|
"slashdot.org".to_string() => vec![
|
||||||
|
Selector::parse("span.story-byline").unwrap(),
|
||||||
|
Selector::parse("div.p").unwrap(),
|
||||||
|
],
|
||||||
|
"theonion.com".to_string() => vec![
|
||||||
|
// Single image joke w/ title
|
||||||
|
Selector::parse("article > section > div > figure").unwrap(),
|
||||||
|
// Single cartoon
|
||||||
|
Selector::parse("article > div > div > figure").unwrap(),
|
||||||
|
// Image at top of article
|
||||||
|
Selector::parse("article > header > div > div > figure").unwrap(),
|
||||||
|
// Article body
|
||||||
|
Selector::parse("article .entry-content > *").unwrap(),
|
||||||
|
],
|
||||||
|
"trofi.github.io".to_string() => vec![
|
||||||
|
Selector::parse("#content").unwrap(),
|
||||||
|
],
|
||||||
|
"www.redox-os.org".to_string() => vec![
|
||||||
|
Selector::parse("div.content").unwrap(),
|
||||||
|
],
|
||||||
|
"www.smbc-comics.com".to_string() => vec![
|
||||||
|
Selector::parse("img#cc-comic").unwrap(),
|
||||||
|
Selector::parse("div#aftercomic img").unwrap(),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
217
server/src/nm.rs
217
server/src/nm.rs
@@ -1,16 +1,15 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::{HashMap, HashSet},
|
||||||
fs::File,
|
fs::File,
|
||||||
hash::{DefaultHasher, Hash, Hasher},
|
|
||||||
time::Instant,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use letterbox_notmuch::Notmuch;
|
||||||
|
use letterbox_shared::{compute_color, Rule};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
||||||
use memmap::MmapOptions;
|
use memmap::MmapOptions;
|
||||||
use notmuch::Notmuch;
|
use sqlx::{types::Json, PgPool};
|
||||||
use sqlx::PgPool;
|
use tracing::{info_span, instrument};
|
||||||
use tracing::instrument;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
compute_offset_limit,
|
compute_offset_limit,
|
||||||
@@ -43,7 +42,9 @@ pub fn is_notmuch_thread_or_id(id: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO(wathiede): decide good error type
|
// TODO(wathiede): decide good error type
|
||||||
pub fn threadset_to_messages(thread_set: notmuch::ThreadSet) -> Result<Vec<Message>, ServerError> {
|
pub fn threadset_to_messages(
|
||||||
|
thread_set: letterbox_notmuch::ThreadSet,
|
||||||
|
) -> Result<Vec<Message>, ServerError> {
|
||||||
for t in thread_set.0 {
|
for t in thread_set.0 {
|
||||||
for _tn in t.0 {}
|
for _tn in t.0 {}
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,6 @@ pub async fn search(
|
|||||||
|
|
||||||
#[instrument(name="nm::tags", skip_all, fields(needs_unread=needs_unread))]
|
#[instrument(name="nm::tags", skip_all, fields(needs_unread=needs_unread))]
|
||||||
pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
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 {
|
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
|
// 10000 is an arbitrary number, if there's more than 10k unread messages, we'll
|
||||||
// get an inaccurate count.
|
// get an inaccurate count.
|
||||||
@@ -121,13 +121,11 @@ pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
|||||||
} else {
|
} else {
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
};
|
};
|
||||||
let tags = nm
|
let tags: Vec<_> = nm
|
||||||
.tags()?
|
.tags()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|tag| {
|
.map(|tag| {
|
||||||
let mut hasher = DefaultHasher::new();
|
let hex = compute_color(&tag);
|
||||||
tag.hash(&mut hasher);
|
|
||||||
let hex = format!("#{:06x}", hasher.finish() % (1 << 24));
|
|
||||||
let unread = if needs_unread {
|
let unread = if needs_unread {
|
||||||
*unread_msg_cnt.get(&tag).unwrap_or(&0)
|
*unread_msg_cnt.get(&tag).unwrap_or(&0)
|
||||||
} else {
|
} else {
|
||||||
@@ -140,8 +138,24 @@ pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
|
|||||||
unread,
|
unread,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.chain(
|
||||||
|
nm.unread_recipients()?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(name, unread)| {
|
||||||
|
let Some(idx) = name.find('@') else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let name = format!("{}/{}", &name[idx..], &name[..idx]);
|
||||||
|
let bg_color = compute_color(&name);
|
||||||
|
Some(Tag {
|
||||||
|
name,
|
||||||
|
fg_color: "white".to_string(),
|
||||||
|
bg_color,
|
||||||
|
unread,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
info!("Fetching tags took {} seconds", now.elapsed().as_secs_f32());
|
|
||||||
Ok(tags)
|
Ok(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,12 +199,14 @@ pub async fn thread(
|
|||||||
|
|
||||||
let to = email_addresses(&path, &m, "to")?;
|
let to = email_addresses(&path, &m, "to")?;
|
||||||
let cc = email_addresses(&path, &m, "cc")?;
|
let cc = email_addresses(&path, &m, "cc")?;
|
||||||
|
let delivered_to = email_addresses(&path, &m, "delivered-to")?.pop();
|
||||||
|
let x_original_to = email_addresses(&path, &m, "x-original-to")?.pop();
|
||||||
let subject = m.headers.get_first_value("subject");
|
let subject = m.headers.get_first_value("subject");
|
||||||
let timestamp = m
|
let timestamp = m
|
||||||
.headers
|
.headers
|
||||||
.get_first_value("date")
|
.get_first_value("date")
|
||||||
.and_then(|d| mailparse::dateparse(&d).ok());
|
.and_then(|d| mailparse::dateparse(&d).ok());
|
||||||
let cid_prefix = shared::urls::cid_prefix(None, &id);
|
let cid_prefix = letterbox_shared::urls::cid_prefix(None, &id);
|
||||||
let base_url = None;
|
let base_url = None;
|
||||||
let mut part_addr = Vec::new();
|
let mut part_addr = Vec::new();
|
||||||
part_addr.push(id.to_string());
|
part_addr.push(id.to_string());
|
||||||
@@ -223,7 +239,7 @@ pub async fn thread(
|
|||||||
}
|
}
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
r#"<p class="view-part-text-plain">{}</p>"#,
|
r#"<p class="view-part-text-plain font-mono whitespace-pre-line">{}</p>"#,
|
||||||
// Trim newlines to prevent excessive white space at the beginning/end of
|
// Trim newlines to prevent excessive white space at the beginning/end of
|
||||||
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
||||||
// header on the first line.
|
// header on the first line.
|
||||||
@@ -304,6 +320,8 @@ pub async fn thread(
|
|||||||
body,
|
body,
|
||||||
path,
|
path,
|
||||||
attachments,
|
attachments,
|
||||||
|
delivered_to,
|
||||||
|
x_original_to,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
messages.reverse();
|
messages.reverse();
|
||||||
@@ -323,7 +341,7 @@ pub async fn thread(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn email_addresses(
|
fn email_addresses(
|
||||||
path: &str,
|
_path: &str,
|
||||||
m: &ParsedMail,
|
m: &ParsedMail,
|
||||||
header_name: &str,
|
header_name: &str,
|
||||||
) -> Result<Vec<Email>, ServerError> {
|
) -> Result<Vec<Email>, ServerError> {
|
||||||
@@ -334,9 +352,7 @@ fn email_addresses(
|
|||||||
for ma in mal.into_inner() {
|
for ma in mal.into_inner() {
|
||||||
match ma {
|
match ma {
|
||||||
mailparse::MailAddr::Group(gi) => {
|
mailparse::MailAddr::Group(gi) => {
|
||||||
if !gi.group_name.contains("ndisclosed") {
|
if !gi.group_name.contains("ndisclosed") {}
|
||||||
println!("[{path}][{header_name}] Group: {gi}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
mailparse::MailAddr::Single(s) => addrs.push(Email {
|
mailparse::MailAddr::Single(s) => addrs.push(Email {
|
||||||
name: s.display_name,
|
name: s.display_name,
|
||||||
@@ -578,7 +594,7 @@ fn flatten_body_parts(parts: &[Body]) -> Body {
|
|||||||
.map(|p| match p {
|
.map(|p| match p {
|
||||||
Body::PlainText(PlainText { text, .. }) => {
|
Body::PlainText(PlainText { text, .. }) => {
|
||||||
format!(
|
format!(
|
||||||
r#"<p class="view-part-text-plain">{}</p>"#,
|
r#"<p class="view-part-text-plain font-mono whitespace-pre-line">{}</p>"#,
|
||||||
// Trim newlines to prevent excessive white space at the beginning/end of
|
// Trim newlines to prevent excessive white space at the beginning/end of
|
||||||
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
// presenation. Leave tabs and spaces incase plain text attempts to center a
|
||||||
// header on the first line.
|
// header on the first line.
|
||||||
@@ -696,7 +712,6 @@ fn walk_attachments_inner<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
|
|||||||
fn extract_attachments(m: &ParsedMail, id: &str) -> Result<Vec<Attachment>, ServerError> {
|
fn extract_attachments(m: &ParsedMail, id: &str) -> Result<Vec<Attachment>, ServerError> {
|
||||||
let mut attachments = Vec::new();
|
let mut attachments = Vec::new();
|
||||||
for (idx, sp) in m.subparts.iter().enumerate() {
|
for (idx, sp) in m.subparts.iter().enumerate() {
|
||||||
info!("sp: {:?}", sp.headers);
|
|
||||||
if let Some(attachment) = extract_attachment(sp, id, &[idx]) {
|
if let Some(attachment) = extract_attachment(sp, id, &[idx]) {
|
||||||
// Filter out inline attachements, they're flattened into the body of the message.
|
// Filter out inline attachements, they're flattened into the body of the message.
|
||||||
if attachment.disposition == DispositionType::Attachment {
|
if attachment.disposition == DispositionType::Attachment {
|
||||||
@@ -913,3 +928,163 @@ WHERE
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(|r| r.url))
|
Ok(row.map(|r| r.url))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* grab email_rules table from sql
|
||||||
|
* For each message with `unprocessed` label
|
||||||
|
* parse the message
|
||||||
|
* pass headers for each message through a matcher using email rules
|
||||||
|
* for each match, add label to message
|
||||||
|
* if any matches were found, remove unprocessed
|
||||||
|
* TODO: how to handle inbox label
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[instrument(name="nm::label_unprocessed", skip_all, fields(dryrun=dryrun, limit=?limit, query=%query))]
|
||||||
|
pub async fn label_unprocessed(
|
||||||
|
nm: &Notmuch,
|
||||||
|
pool: &PgPool,
|
||||||
|
dryrun: bool,
|
||||||
|
limit: Option<usize>,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Box<[String]>, ServerError> {
|
||||||
|
use futures::StreamExt;
|
||||||
|
let ids = nm.message_ids(query)?;
|
||||||
|
info!(
|
||||||
|
"Processing {limit:?} of {} messages with '{query}'",
|
||||||
|
ids.len()
|
||||||
|
);
|
||||||
|
let rules: Vec<_> = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT rule as "rule: Json<Rule>"
|
||||||
|
FROM email_rule
|
||||||
|
ORDER BY sort_order
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch(pool)
|
||||||
|
.map(|r| r.unwrap().rule.0)
|
||||||
|
.collect()
|
||||||
|
.await;
|
||||||
|
/*
|
||||||
|
use letterbox_shared::{Match, MatchType};
|
||||||
|
let rules = vec![Rule {
|
||||||
|
stop_on_match: false,
|
||||||
|
matches: vec![Match {
|
||||||
|
match_type: MatchType::From,
|
||||||
|
needle: "eftours".to_string(),
|
||||||
|
}],
|
||||||
|
tag: "EFTours".to_string(),
|
||||||
|
}];
|
||||||
|
*/
|
||||||
|
info!("Loaded {} rules", rules.len());
|
||||||
|
|
||||||
|
let ids = if let Some(limit) = limit {
|
||||||
|
&ids[..limit]
|
||||||
|
} else {
|
||||||
|
&ids[..]
|
||||||
|
};
|
||||||
|
let mut add_mutations = HashMap::new();
|
||||||
|
let mut rm_mutations = HashMap::new();
|
||||||
|
for id in ids {
|
||||||
|
let id = format!("id:{id}");
|
||||||
|
let files = nm.files(&id)?;
|
||||||
|
// Only process the first file path is multiple files have the same id
|
||||||
|
let path = files.iter().next().unwrap();
|
||||||
|
let file = File::open(&path)?;
|
||||||
|
info!("parsing {path}");
|
||||||
|
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
||||||
|
let m = info_span!("parse_mail", path = path).in_scope(|| parse_mail(&mmap))?;
|
||||||
|
let (matched_rule, add_tags) = find_tags(&rules, &m.headers);
|
||||||
|
if matched_rule {
|
||||||
|
if dryrun {
|
||||||
|
info!(
|
||||||
|
"\nAdd tags: {add_tags:?}\nTo: {} From: {} Subject: {}\n",
|
||||||
|
m.headers.get_first_value("to").expect("no from header"),
|
||||||
|
m.headers.get_first_value("from").expect("no from header"),
|
||||||
|
m.headers
|
||||||
|
.get_first_value("subject")
|
||||||
|
.expect("no subject header")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for t in &add_tags {
|
||||||
|
//nm.tag_add(t, &id)?;
|
||||||
|
add_mutations
|
||||||
|
.entry(t.to_string())
|
||||||
|
.or_insert_with(|| Vec::new())
|
||||||
|
.push(id.clone());
|
||||||
|
}
|
||||||
|
if add_tags.contains("spam") || add_tags.contains("Spam") {
|
||||||
|
//nm.tag_remove("unread", &id)?;
|
||||||
|
let t = "unread".to_string();
|
||||||
|
rm_mutations
|
||||||
|
.entry(t)
|
||||||
|
.or_insert_with(|| Vec::new())
|
||||||
|
.push(id.clone());
|
||||||
|
}
|
||||||
|
if !add_tags.contains("inbox") {
|
||||||
|
//nm.tag_remove("inbox", &id)?;
|
||||||
|
let t = "inbox".to_string();
|
||||||
|
rm_mutations
|
||||||
|
.entry(t)
|
||||||
|
.or_insert_with(|| Vec::new())
|
||||||
|
.push(id.clone());
|
||||||
|
}
|
||||||
|
//nm.tag_remove("unprocessed", &id)?;
|
||||||
|
} else {
|
||||||
|
if add_tags.is_empty() {
|
||||||
|
let t = "Grey".to_string();
|
||||||
|
add_mutations
|
||||||
|
.entry(t)
|
||||||
|
.or_insert_with(|| Vec::new())
|
||||||
|
.push(id.clone());
|
||||||
|
}
|
||||||
|
//nm.tag_remove("inbox", &id)?;
|
||||||
|
let t = "inbox".to_string();
|
||||||
|
rm_mutations
|
||||||
|
.entry(t)
|
||||||
|
.or_insert_with(|| Vec::new())
|
||||||
|
.push(id.clone());
|
||||||
|
}
|
||||||
|
let t = "unprocessed".to_string();
|
||||||
|
rm_mutations
|
||||||
|
.entry(t)
|
||||||
|
.or_insert_with(|| Vec::new())
|
||||||
|
.push(id.clone());
|
||||||
|
}
|
||||||
|
info!("Adding {} distinct labels", add_mutations.len());
|
||||||
|
for (tag, ids) in add_mutations.iter() {
|
||||||
|
info!(" {tag}: {}", ids.len());
|
||||||
|
if !dryrun {
|
||||||
|
let ids: Vec<_> = ids.iter().map(|s| s.as_str()).collect();
|
||||||
|
info_span!("tags_add", tag = tag, count = ids.len())
|
||||||
|
.in_scope(|| nm.tags_add(tag, &ids))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Removing {} distinct labels", rm_mutations.len());
|
||||||
|
for (tag, ids) in rm_mutations.iter() {
|
||||||
|
info!(" {tag}: {}", ids.len());
|
||||||
|
if !dryrun {
|
||||||
|
let ids: Vec<_> = ids.iter().map(|s| s.as_str()).collect();
|
||||||
|
info_span!("tags_remove", tag = tag, count = ids.len())
|
||||||
|
.in_scope(|| nm.tags_remove(tag, &ids))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ids.into())
|
||||||
|
}
|
||||||
|
fn find_tags<'a, 'b>(rules: &'a [Rule], headers: &'b [MailHeader]) -> (bool, HashSet<&'a str>) {
|
||||||
|
let mut matched_rule = false;
|
||||||
|
let mut add_tags = HashSet::new();
|
||||||
|
for rule in rules {
|
||||||
|
for hdr in headers {
|
||||||
|
if rule.is_match(&hdr.get_key(), &hdr.get_value()) {
|
||||||
|
//info!("Matched {rule:?}");
|
||||||
|
matched_rule = true;
|
||||||
|
add_tags.insert(rule.tag.as_str());
|
||||||
|
if rule.stop_on_match {
|
||||||
|
return (true, add_tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (matched_rule, add_tags);
|
||||||
|
}
|
||||||
|
|||||||
35
server/src/ws.rs
Normal file
35
server/src/ws.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use std::{collections::HashMap, net::SocketAddr};
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket};
|
||||||
|
use letterbox_shared::WebsocketMessage;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ConnectionTracker {
|
||||||
|
peers: HashMap<SocketAddr, WebSocket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionTracker {
|
||||||
|
pub async fn add_peer(&mut self, socket: WebSocket, who: SocketAddr) {
|
||||||
|
warn!("adding {who:?} to connection tracker");
|
||||||
|
self.peers.insert(who, socket);
|
||||||
|
self.send_message_all(WebsocketMessage::RefreshMessages)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
pub async fn send_message_all(&mut self, msg: WebsocketMessage) {
|
||||||
|
info!("send_message_all {msg}");
|
||||||
|
let m = serde_json::to_string(&msg).expect("failed to json encode WebsocketMessage");
|
||||||
|
let mut bad_peers = Vec::new();
|
||||||
|
for (who, socket) in &mut self.peers.iter_mut() {
|
||||||
|
if let Err(e) = socket.send(Message::Text(m.clone().into())).await {
|
||||||
|
warn!("{:?} is bad, scheduling for removal: {e}", who);
|
||||||
|
bad_peers.push(who.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for b in bad_peers {
|
||||||
|
info!("removing bad peer {b:?}");
|
||||||
|
self.peers.remove(&b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
server/static/vars.css
Normal file
42
server/static/vars.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
:root {
|
||||||
|
--active-brightness: 0.85;
|
||||||
|
--border-radius: 5px;
|
||||||
|
--box-shadow: 2px 2px 10px;
|
||||||
|
--color-accent: #118bee15;
|
||||||
|
--color-bg: #fff;
|
||||||
|
--color-bg-secondary: #e9e9e9;
|
||||||
|
--color-link: #118bee;
|
||||||
|
--color-secondary: #920de9;
|
||||||
|
--color-secondary-accent: #920de90b;
|
||||||
|
--color-shadow: #f4f4f4;
|
||||||
|
--color-table: #118bee;
|
||||||
|
--color-text: #000;
|
||||||
|
--color-text-secondary: #999;
|
||||||
|
--color-scrollbar: #cacae8;
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||||
|
--hover-brightness: 1.2;
|
||||||
|
--justify-important: center;
|
||||||
|
--justify-normal: left;
|
||||||
|
--line-height: 1.5;
|
||||||
|
/*
|
||||||
|
--width-card: 285px;
|
||||||
|
--width-card-medium: 460px;
|
||||||
|
--width-card-wide: 800px;
|
||||||
|
*/
|
||||||
|
--width-content: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root[color-mode="user"] {
|
||||||
|
--color-accent: #0097fc4f;
|
||||||
|
--color-bg: #333;
|
||||||
|
--color-bg-secondary: #555;
|
||||||
|
--color-link: #0097fc;
|
||||||
|
--color-secondary: #e20de9;
|
||||||
|
--color-secondary-accent: #e20de94f;
|
||||||
|
--color-shadow: #bbbbbb20;
|
||||||
|
--color-table: #0097fc;
|
||||||
|
--color-text: #f7f7f7;
|
||||||
|
--color-text-secondary: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "shared"
|
name = "letterbox-shared"
|
||||||
version = "0.0.115"
|
description = "Shared module for letterbox"
|
||||||
edition = "2021"
|
authors.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
build-info = "0.0.38"
|
build-info = "0.0.40"
|
||||||
notmuch = { path = "../notmuch" }
|
letterbox-notmuch = { path = "../notmuch", version = "0.17.17", registry = "xinu" }
|
||||||
serde = { version = "1.0.147", features = ["derive"] }
|
regex = "1.11.1"
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
sqlx = "0.8.5"
|
||||||
|
strum_macros = "0.27.1"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
use std::{
|
||||||
|
convert::Infallible,
|
||||||
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
|
str::FromStr,
|
||||||
|
};
|
||||||
|
|
||||||
use build_info::{BuildInfo, VersionControl};
|
use build_info::{BuildInfo, VersionControl};
|
||||||
use notmuch::SearchSummary;
|
use letterbox_notmuch::SearchSummary;
|
||||||
|
use regex::{RegexBuilder, RegexSetBuilder};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct SearchResult {
|
pub struct SearchResult {
|
||||||
@@ -13,11 +19,20 @@ pub struct SearchResult {
|
|||||||
pub total: usize,
|
pub total: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug, strum_macros::Display)]
|
||||||
pub struct Message {}
|
pub enum WebsocketMessage {
|
||||||
|
RefreshMessages,
|
||||||
|
}
|
||||||
|
|
||||||
pub mod urls {
|
pub mod urls {
|
||||||
pub const MOUNT_POINT: &'static str = "/api";
|
pub const MOUNT_POINT: &'static str = "/api";
|
||||||
|
pub fn view_original(host: Option<&str>, id: &str) -> String {
|
||||||
|
if let Some(host) = host {
|
||||||
|
format!("//{host}/api/original/{id}")
|
||||||
|
} else {
|
||||||
|
format!("/api/original/{id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn cid_prefix(host: Option<&str>, cid: &str) -> String {
|
pub fn cid_prefix(host: Option<&str>, cid: &str) -> String {
|
||||||
if let Some(host) = host {
|
if let Some(host) = host {
|
||||||
format!("//{host}/api/cid/{cid}/")
|
format!("//{host}/api/cid/{cid}/")
|
||||||
@@ -56,3 +71,198 @@ pub fn compute_color(data: &str) -> String {
|
|||||||
data.hash(&mut hasher);
|
data.hash(&mut hasher);
|
||||||
format!("#{:06x}", hasher.finish() % (1 << 24))
|
format!("#{:06x}", hasher.finish() % (1 << 24))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize,
|
||||||
|
)]
|
||||||
|
pub enum MatchType {
|
||||||
|
From,
|
||||||
|
Sender,
|
||||||
|
To,
|
||||||
|
Cc,
|
||||||
|
Subject,
|
||||||
|
ListId,
|
||||||
|
DeliveredTo,
|
||||||
|
XForwardedTo,
|
||||||
|
ReplyTo,
|
||||||
|
XOriginalTo,
|
||||||
|
XSpam,
|
||||||
|
Body,
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Match {
|
||||||
|
pub match_type: MatchType,
|
||||||
|
pub needle: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Rule {
|
||||||
|
pub stop_on_match: bool,
|
||||||
|
pub matches: Vec<Match>,
|
||||||
|
pub tag: String,
|
||||||
|
}
|
||||||
|
impl Rule {
|
||||||
|
pub fn is_match(&self, header_key: &str, header_value: &str) -> bool {
|
||||||
|
let pats: Vec<_> = self
|
||||||
|
.matches
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| match m.match_type {
|
||||||
|
MatchType::To => Some("^(to|cc|bcc|x-original-to)$"),
|
||||||
|
MatchType::From => Some("^from$"),
|
||||||
|
MatchType::Sender => Some("^sender$"),
|
||||||
|
MatchType::Subject => Some("^subject$"),
|
||||||
|
MatchType::ListId => Some("^list-id$"),
|
||||||
|
MatchType::XOriginalTo => Some("^x-original-to$"),
|
||||||
|
MatchType::ReplyTo => Some("^reply-to$"),
|
||||||
|
MatchType::XSpam => Some("^x-spam$"),
|
||||||
|
MatchType::Body => None,
|
||||||
|
c => panic!("TODO handle '{c:?}' match type"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let set = RegexSetBuilder::new(&pats)
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
.expect("failed to compile regex for matches");
|
||||||
|
let matches: Vec<_> = set.matches(header_key).into_iter().collect();
|
||||||
|
if !matches.is_empty() {
|
||||||
|
//info!("matched key '{header_key}' '{header_value}'");
|
||||||
|
for m_idx in matches {
|
||||||
|
let needle = regex::escape(&self.matches[m_idx].needle);
|
||||||
|
let pat = RegexBuilder::new(&needle)
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
.expect("failed to compile regex for needle");
|
||||||
|
if pat.is_match(header_value) {
|
||||||
|
debug!("{header_key} matched {header_value} against {needle}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod matches {
|
||||||
|
// From https://linux.die.net/man/5/procmailrc
|
||||||
|
// If the regular expression contains '^TO_' it will be substituted by '(^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To):(.*[^-a-zA-Z0-9_.])?)'
|
||||||
|
// If the regular expression contains '^TO' it will be substituted by '(^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To):(.*[^a-zA-Z])?)', which should catch all destination specifications containing a specific word.
|
||||||
|
|
||||||
|
pub const TO: &'static str = "TO";
|
||||||
|
pub const CC: &'static str = "Cc";
|
||||||
|
pub const TOCC: &'static str = "(TO|Cc)";
|
||||||
|
pub const FROM: &'static str = "From";
|
||||||
|
pub const SENDER: &'static str = "Sender";
|
||||||
|
pub const SUBJECT: &'static str = "Subject";
|
||||||
|
pub const DELIVERED_TO: &'static str = "Delivered-To";
|
||||||
|
pub const X_FORWARDED_TO: &'static str = "X-Forwarded-To";
|
||||||
|
pub const REPLY_TO: &'static str = "Reply-To";
|
||||||
|
pub const X_ORIGINAL_TO: &'static str = "X-Original-To";
|
||||||
|
pub const LIST_ID: &'static str = "List-ID";
|
||||||
|
pub const X_SPAM: &'static str = "X-Spam";
|
||||||
|
pub const X_SPAM_FLAG: &'static str = "X-Spam-Flag";
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Match {
|
||||||
|
type Err = Infallible;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
// Examples:
|
||||||
|
// "* 1^0 ^TOsonyrewards.com@xinu.tv"
|
||||||
|
// "* ^TOsonyrewards.com@xinu.tv"
|
||||||
|
let mut it = s.split_whitespace().skip(1);
|
||||||
|
let mut needle = it.next().unwrap();
|
||||||
|
if needle == "1^0" {
|
||||||
|
needle = it.next().unwrap();
|
||||||
|
}
|
||||||
|
let mut needle = vec![needle];
|
||||||
|
needle.extend(it);
|
||||||
|
let needle = needle.join(" ");
|
||||||
|
let first = needle.chars().nth(0).unwrap_or(' ');
|
||||||
|
use matches::*;
|
||||||
|
if first == '^' {
|
||||||
|
let needle = &needle[1..];
|
||||||
|
if needle.starts_with(TO) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::To,
|
||||||
|
needle: cleanup_match(TO, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(FROM) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::From,
|
||||||
|
needle: cleanup_match(FROM, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(CC) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::Cc,
|
||||||
|
needle: cleanup_match(CC, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(TOCC) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::To,
|
||||||
|
needle: cleanup_match(TOCC, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(SENDER) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::Sender,
|
||||||
|
needle: cleanup_match(SENDER, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(SUBJECT) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::Subject,
|
||||||
|
needle: cleanup_match(SUBJECT, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(X_ORIGINAL_TO) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::XOriginalTo,
|
||||||
|
needle: cleanup_match(X_ORIGINAL_TO, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(LIST_ID) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::ListId,
|
||||||
|
needle: cleanup_match(LIST_ID, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(REPLY_TO) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::ReplyTo,
|
||||||
|
needle: cleanup_match(REPLY_TO, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(X_SPAM_FLAG) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::XSpam,
|
||||||
|
needle: '*'.to_string(),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(X_SPAM) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::XSpam,
|
||||||
|
needle: '*'.to_string(),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(DELIVERED_TO) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::DeliveredTo,
|
||||||
|
needle: cleanup_match(DELIVERED_TO, needle),
|
||||||
|
});
|
||||||
|
} else if needle.starts_with(X_FORWARDED_TO) {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::XForwardedTo,
|
||||||
|
needle: cleanup_match(X_FORWARDED_TO, needle),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unreachable!("needle: '{needle}'")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(Match {
|
||||||
|
match_type: MatchType::Body,
|
||||||
|
needle: cleanup_match("", &needle),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn unescape(s: &str) -> String {
|
||||||
|
s.replace('\\', "")
|
||||||
|
}
|
||||||
|
pub fn cleanup_match(prefix: &str, s: &str) -> String {
|
||||||
|
unescape(&s[prefix.len()..]).replace(".*", "")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,50 +1,58 @@
|
|||||||
[package]
|
[package]
|
||||||
version = "0.0.115"
|
name = "letterbox-web"
|
||||||
name = "letterbox"
|
description = "Web frontend for letterbox"
|
||||||
repository = "https://github.com/seed-rs/seed-quickstart"
|
authors.workspace = true
|
||||||
authors = ["Bill Thiede <git@xinu.tv>"]
|
edition.workspace = true
|
||||||
description = "App Description"
|
license.workspace = true
|
||||||
categories = ["category"]
|
publish.workspace = true
|
||||||
license = "MIT"
|
repository.workspace = true
|
||||||
readme = "./README.md"
|
version.workspace = true
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
build-info-build = "0.0.38"
|
build-info-build = "0.0.40"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wasm-bindgen-test = "0.3.33"
|
wasm-bindgen-test = "0.3.50"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
console_error_panic_hook = "0.1.7"
|
console_error_panic_hook = "0.1.7"
|
||||||
log = "0.4.17"
|
log = "0.4.27"
|
||||||
seed = { version = "0.10.0", features = ["routing"] }
|
seed = { version = "0.10.0", features = ["routing"] }
|
||||||
#seed = "0.9.2"
|
#seed = "0.9.2"
|
||||||
console_log = { version = "0.1.0", registry = "xinu" }
|
console_log = { version = "0.1.4", registry = "xinu" }
|
||||||
serde = { version = "1.0.147", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
notmuch = { path = "../notmuch" }
|
itertools = "0.14.0"
|
||||||
shared = { path = "../shared" }
|
serde_json = { version = "1.0.140", features = ["unbounded_depth"] }
|
||||||
itertools = "0.10.5"
|
chrono = "0.4.40"
|
||||||
serde_json = { version = "1.0.93", features = ["unbounded_depth"] }
|
graphql_client = "0.14.0"
|
||||||
chrono = "0.4.31"
|
thiserror = "2.0.12"
|
||||||
graphql_client = "0.13.0"
|
gloo-net = { version = "0.6.0", features = ["json", "serde_json"] }
|
||||||
thiserror = "1.0.50"
|
|
||||||
seed_hooks = { git = "https://github.com/wathiede/styles_hooks", package = "seed_hooks", branch = "main" }
|
|
||||||
gloo-net = { version = "0.4.0", features = ["json", "serde_json"] }
|
|
||||||
human_format = "1.1.0"
|
human_format = "1.1.0"
|
||||||
build-info = "0.0.38"
|
build-info = "0.0.40"
|
||||||
wasm-bindgen = "0.2.95"
|
wasm-bindgen = "=0.2.100"
|
||||||
|
uuid = { version = "1.16.0", features = [
|
||||||
|
"js",
|
||||||
|
] } # direct dep to set js feature, prevents Rng issues
|
||||||
|
letterbox-shared = { version = "0.17.9", registry = "xinu" }
|
||||||
|
seed_hooks = { version = "0.4.1", registry = "xinu" }
|
||||||
|
strum_macros = "0.27.1"
|
||||||
|
gloo-console = "0.3.0"
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
wasm-sockets = "1.0.0"
|
||||||
|
|
||||||
[package.metadata.wasm-pack.profile.release]
|
[package.metadata.wasm-pack.profile.release]
|
||||||
wasm-opt = ['-Os']
|
wasm-opt = ['-Os']
|
||||||
|
|
||||||
[dependencies.web-sys]
|
[dependencies.web-sys]
|
||||||
version = "0.3.58"
|
version = "0.3.77"
|
||||||
features = [
|
features = [
|
||||||
"Clipboard",
|
"Clipboard",
|
||||||
"DomRect",
|
"DomRect",
|
||||||
"Element",
|
"Element",
|
||||||
|
"History",
|
||||||
"MediaQueryList",
|
"MediaQueryList",
|
||||||
"Navigator",
|
"Navigator",
|
||||||
|
"Performance",
|
||||||
|
"ScrollRestoration",
|
||||||
"Window",
|
"Window",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ release = false
|
|||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
port = 6758
|
port = 6758
|
||||||
|
|
||||||
|
[[proxy]]
|
||||||
|
ws = true
|
||||||
|
backend = "ws://localhost:9345/api/ws"
|
||||||
|
|
||||||
[[proxy]]
|
[[proxy]]
|
||||||
backend = "http://localhost:9345/api/"
|
backend = "http://localhost:9345/api/"
|
||||||
|
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:9345/notification/"
|
||||||
|
|
||||||
[[hooks]]
|
[[hooks]]
|
||||||
stage = "pre_build"
|
stage = "pre_build"
|
||||||
command = "printf"
|
command = "printf"
|
||||||
|
|||||||
3
web/graphql/catchup.graphql
Normal file
3
web/graphql/catchup.graphql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
query CatchupQuery($query: String!) {
|
||||||
|
catchup(query: $query)
|
||||||
|
}
|
||||||
@@ -671,6 +671,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"args": [],
|
||||||
|
"deprecationReason": null,
|
||||||
|
"description": null,
|
||||||
|
"isDeprecated": false,
|
||||||
|
"name": "xOriginalTo",
|
||||||
|
"type": {
|
||||||
|
"kind": "OBJECT",
|
||||||
|
"name": "Email",
|
||||||
|
"ofType": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"args": [],
|
||||||
|
"deprecationReason": null,
|
||||||
|
"description": null,
|
||||||
|
"isDeprecated": false,
|
||||||
|
"name": "deliveredTo",
|
||||||
|
"type": {
|
||||||
|
"kind": "OBJECT",
|
||||||
|
"name": "Email",
|
||||||
|
"ofType": null
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"args": [],
|
"args": [],
|
||||||
"deprecationReason": null,
|
"deprecationReason": null,
|
||||||
@@ -1268,6 +1292,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"defaultValue": null,
|
||||||
|
"description": null,
|
||||||
|
"name": "query",
|
||||||
|
"type": {
|
||||||
|
"kind": "NON_NULL",
|
||||||
|
"name": null,
|
||||||
|
"ofType": {
|
||||||
|
"kind": "SCALAR",
|
||||||
|
"name": "String",
|
||||||
|
"ofType": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deprecationReason": null,
|
||||||
|
"description": null,
|
||||||
|
"isDeprecated": false,
|
||||||
|
"name": "catchup",
|
||||||
|
"type": {
|
||||||
|
"kind": "NON_NULL",
|
||||||
|
"name": null,
|
||||||
|
"ofType": {
|
||||||
|
"kind": "LIST",
|
||||||
|
"name": null,
|
||||||
|
"ofType": {
|
||||||
|
"kind": "NON_NULL",
|
||||||
|
"name": null,
|
||||||
|
"ofType": {
|
||||||
|
"kind": "SCALAR",
|
||||||
|
"name": "String",
|
||||||
|
"ofType": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"args": [
|
"args": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ query ShowThreadQuery($threadId: String!) {
|
|||||||
name
|
name
|
||||||
addr
|
addr
|
||||||
}
|
}
|
||||||
|
xOriginalTo {
|
||||||
|
name
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
deliveredTo {
|
||||||
|
name
|
||||||
|
addr
|
||||||
|
}
|
||||||
timestamp
|
timestamp
|
||||||
body {
|
body {
|
||||||
__typename
|
__typename
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
|
||||||
integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
|
integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" />
|
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" />
|
||||||
<!-- tall thin font for user icon -->
|
<!-- tall thin font for user icon -->
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet">
|
||||||
<!-- <link data-trunk rel="css" href="static/site-specific.css" /> -->
|
<!-- <link data-trunk rel="css" href="static/site-specific.css" /> -->
|
||||||
|
<link data-trunk rel="css" href="static/vars.css" />
|
||||||
<link data-trunk rel="tailwind-css" href="./src/tailwind.css" />
|
<link data-trunk rel="tailwind-css" href="./src/tailwind.css" />
|
||||||
|
<link data-trunk rel="css" href="static/overrides.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ use serde::{de::DeserializeOwned, Serialize};
|
|||||||
)]
|
)]
|
||||||
pub struct FrontPageQuery;
|
pub struct FrontPageQuery;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "graphql/schema.json",
|
||||||
|
query_path = "graphql/catchup.graphql",
|
||||||
|
response_derives = "Debug"
|
||||||
|
)]
|
||||||
|
pub struct CatchupQuery;
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "graphql/schema.json",
|
schema_path = "graphql/schema.json",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// - it's useful when you want to check your code with `cargo make verify`
|
// - it's useful when you want to check your code with `cargo make verify`
|
||||||
// but some rules are too "annoying" or are not applicable for your case.)
|
// but some rules are too "annoying" or are not applicable for your case.)
|
||||||
#![allow(clippy::wildcard_imports)]
|
#![allow(clippy::wildcard_imports)]
|
||||||
|
// Until https://github.com/rust-lang/rust/issues/138762 is addressed in dependencies
|
||||||
|
#![allow(wasm_c_abi)]
|
||||||
|
|
||||||
use log::Level;
|
use log::Level;
|
||||||
use seed::App;
|
use seed::App;
|
||||||
@@ -11,6 +13,7 @@ mod consts;
|
|||||||
mod graphql;
|
mod graphql;
|
||||||
mod state;
|
mod state;
|
||||||
mod view;
|
mod view;
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// This provides better error messages in debug mode.
|
// This provides better error messages in debug mode.
|
||||||
@@ -18,6 +21,9 @@ fn main() {
|
|||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let lvl = Level::Debug;
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
let lvl = Level::Info;
|
let lvl = Level::Info;
|
||||||
console_log::init_with_level(lvl).expect("failed to initialize console logging");
|
console_log::init_with_level(lvl).expect("failed to initialize console logging");
|
||||||
// Mount the `app` to the element with the `id` "app".
|
// Mount the `app` to the element with the `id` "app".
|
||||||
|
|||||||
226
web/src/state.rs
226
web/src/state.rs
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
|
use letterbox_shared::WebsocketMessage;
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use seed::{prelude::*, *};
|
use seed::{prelude::*, *};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -11,6 +12,7 @@ use crate::{
|
|||||||
consts::SEARCH_RESULTS_PER_PAGE,
|
consts::SEARCH_RESULTS_PER_PAGE,
|
||||||
graphql,
|
graphql,
|
||||||
graphql::{front_page_query::*, send_graphql, show_thread_query::*},
|
graphql::{front_page_query::*, send_graphql, show_thread_query::*},
|
||||||
|
websocket,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Used to fake the unread string while in development
|
/// Used to fake the unread string while in development
|
||||||
@@ -27,18 +29,23 @@ pub fn unread_query() -> &'static str {
|
|||||||
|
|
||||||
// `init` describes what should happen when your app started.
|
// `init` describes what should happen when your app started.
|
||||||
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
||||||
let version = shared::build_version(bi);
|
let version = letterbox_shared::build_version(bi);
|
||||||
info!("Build Info: {}", version);
|
info!("Build Info: {}", version);
|
||||||
|
// Disable restoring to scroll position when navigating
|
||||||
|
window()
|
||||||
|
.history()
|
||||||
|
.expect("couldn't get history")
|
||||||
|
.set_scroll_restoration(web_sys::ScrollRestoration::Manual)
|
||||||
|
.expect("failed to set scroll restoration to manual");
|
||||||
if url.hash().is_none() {
|
if url.hash().is_none() {
|
||||||
orders.request_url(urls::search(unread_query(), 0));
|
orders.request_url(urls::search(unread_query(), 0));
|
||||||
} else {
|
} else {
|
||||||
orders.notify(subs::UrlRequested::new(url));
|
orders.request_url(url.clone());
|
||||||
};
|
};
|
||||||
orders.stream(streams::window_event(Ev::Resize, |_| Msg::OnResize));
|
|
||||||
// TODO(wathiede): only do this while viewing the index? Or maybe add a new message that force
|
// TODO(wathiede): only do this while viewing the index? Or maybe add a new message that force
|
||||||
// 'notmuch new' on the server periodically?
|
// 'notmuch new' on the server periodically?
|
||||||
orders.stream(streams::interval(30_000, || Msg::RefreshStart));
|
//orders.stream(streams::interval(30_000, || Msg::RefreshStart));
|
||||||
orders.subscribe(on_url_changed);
|
orders.subscribe(Msg::OnUrlChanged);
|
||||||
orders.stream(streams::window_event(Ev::Scroll, |_| Msg::WindowScrolled));
|
orders.stream(streams::window_event(Ev::Scroll, |_| Msg::WindowScrolled));
|
||||||
|
|
||||||
build_info::build_info!(fn bi);
|
build_info::build_info!(fn bi);
|
||||||
@@ -53,18 +60,24 @@ pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
|||||||
client: version,
|
client: version,
|
||||||
server: None,
|
server: None,
|
||||||
},
|
},
|
||||||
|
catchup: None,
|
||||||
|
last_url: Url::current(),
|
||||||
|
websocket: websocket::init("/api/ws", &mut orders.proxy(Msg::WebSocket)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_url_changed(uc: subs::UrlChanged) -> Msg {
|
fn on_url_changed(old: &Url, mut new: Url) -> Msg {
|
||||||
let mut url = uc.0;
|
let did_change = *old != new;
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
if did_change {
|
||||||
|
messages.push(Msg::ScrollToTop)
|
||||||
|
}
|
||||||
info!(
|
info!(
|
||||||
"url changed '{}', history {}",
|
"url changed\nold '{old}'\nnew '{new}', history {}",
|
||||||
url,
|
|
||||||
history().length().unwrap_or(0)
|
history().length().unwrap_or(0)
|
||||||
);
|
);
|
||||||
let hpp = url.remaining_hash_path_parts();
|
let hpp = new.remaining_hash_path_parts();
|
||||||
match hpp.as_slice() {
|
let msg = match hpp.as_slice() {
|
||||||
["t", tid] => Msg::ShowThreadRequest {
|
["t", tid] => Msg::ShowThreadRequest {
|
||||||
thread_id: tid.to_string(),
|
thread_id: tid.to_string(),
|
||||||
},
|
},
|
||||||
@@ -101,11 +114,14 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
|
|||||||
last: None,
|
last: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
messages.push(msg);
|
||||||
|
Msg::MultiMsg(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// `update` describes how to handle each `Msg`.
|
// `update` describes how to handle each `Msg`.
|
||||||
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
info!("update({})", msg);
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Noop => {}
|
Msg::Noop => {}
|
||||||
Msg::RefreshStart => {
|
Msg::RefreshStart => {
|
||||||
@@ -131,7 +147,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
orders.perform_cmd(async move { Msg::Refresh });
|
orders.perform_cmd(async move { Msg::Refresh });
|
||||||
}
|
}
|
||||||
Msg::Refresh => {
|
Msg::Refresh => {
|
||||||
orders.perform_cmd(async move { on_url_changed(subs::UrlChanged(Url::current())) });
|
orders.request_url(Url::current());
|
||||||
}
|
}
|
||||||
Msg::Reload => {
|
Msg::Reload => {
|
||||||
window()
|
window()
|
||||||
@@ -139,7 +155,10 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
.reload()
|
.reload()
|
||||||
.expect("failed to reload window");
|
.expect("failed to reload window");
|
||||||
}
|
}
|
||||||
Msg::OnResize => (),
|
Msg::OnUrlChanged(new_url) => {
|
||||||
|
orders.send_msg(on_url_changed(&model.last_url, new_url.0.clone()));
|
||||||
|
model.last_url = new_url.0;
|
||||||
|
}
|
||||||
|
|
||||||
Msg::NextPage => {
|
Msg::NextPage => {
|
||||||
match &model.context {
|
match &model.context {
|
||||||
@@ -181,10 +200,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
Msg::GoToSearchResults => {
|
Msg::GoToSearchResults => {
|
||||||
let url = urls::search(&model.query, 0);
|
orders.send_msg(Msg::SearchQuery(model.query.clone()));
|
||||||
info!("GoToSearchRestuls Start");
|
|
||||||
orders.request_url(url);
|
|
||||||
info!("GoToSearchRestuls End");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Msg::UpdateQuery(query) => model.query = query,
|
Msg::UpdateQuery(query) => model.query = query,
|
||||||
@@ -279,7 +295,9 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Msg::FrontPageResult(Err(e)) => error!("error FrontPageResult: {e:?}"),
|
Msg::FrontPageResult(Err(e)) => {
|
||||||
|
error!("error FrontPageResult: {e:?}");
|
||||||
|
}
|
||||||
Msg::FrontPageResult(Ok(graphql_client::Response {
|
Msg::FrontPageResult(Ok(graphql_client::Response {
|
||||||
data: None,
|
data: None,
|
||||||
errors: None,
|
errors: None,
|
||||||
@@ -307,7 +325,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
info!("pager {:#?}", data.search.page_info);
|
|
||||||
let selected_threads = 'context: {
|
let selected_threads = 'context: {
|
||||||
if let Context::SearchResult {
|
if let Context::SearchResult {
|
||||||
results,
|
results,
|
||||||
@@ -390,6 +407,38 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
Msg::ShowThreadResult(bad) => {
|
Msg::ShowThreadResult(bad) => {
|
||||||
error!("show_thread_query error: {bad:#?}");
|
error!("show_thread_query error: {bad:#?}");
|
||||||
}
|
}
|
||||||
|
Msg::CatchupRequest { query } => {
|
||||||
|
orders.perform_cmd(async move {
|
||||||
|
Msg::CatchupResult(
|
||||||
|
send_graphql::<_, graphql::catchup_query::ResponseData>(
|
||||||
|
graphql::CatchupQuery::build_query(graphql::catchup_query::Variables {
|
||||||
|
query,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Msg::CatchupResult(Ok(graphql_client::Response {
|
||||||
|
data: Some(data), ..
|
||||||
|
})) => {
|
||||||
|
let items = data.catchup;
|
||||||
|
if items.is_empty() {
|
||||||
|
orders.send_msg(Msg::GoToSearchResults);
|
||||||
|
model.catchup = None;
|
||||||
|
} else {
|
||||||
|
orders.request_url(urls::thread(&items[0]));
|
||||||
|
model.catchup = Some(Catchup {
|
||||||
|
items: items
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| CatchupItem { id, seen: false })
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Msg::CatchupResult(bad) => {
|
||||||
|
error!("catchup_query error: {bad:#?}");
|
||||||
|
}
|
||||||
Msg::SelectionSetNone => {
|
Msg::SelectionSetNone => {
|
||||||
if let Context::SearchResult {
|
if let Context::SearchResult {
|
||||||
selected_threads, ..
|
selected_threads, ..
|
||||||
@@ -496,18 +545,19 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
}
|
}
|
||||||
Msg::MultiMsg(msgs) => msgs.into_iter().for_each(|msg| update(msg, model, orders)),
|
Msg::MultiMsg(msgs) => msgs.into_iter().for_each(|msg| update(msg, model, orders)),
|
||||||
Msg::CopyToClipboard(text) => {
|
Msg::CopyToClipboard(text) => {
|
||||||
let clipboard = seed::window()
|
let clipboard = seed::window().navigator().clipboard();
|
||||||
.navigator()
|
|
||||||
.clipboard()
|
|
||||||
.expect("couldn't get clipboard");
|
|
||||||
orders.perform_cmd(async move {
|
orders.perform_cmd(async move {
|
||||||
wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text))
|
wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text))
|
||||||
.await
|
.await
|
||||||
.expect("failed to copy to clipboard");
|
.expect("failed to copy to clipboard");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Msg::ScrollToTop => {
|
||||||
|
info!("scrolling to the top");
|
||||||
|
web_sys::window().unwrap().scroll_to_with_x_and_y(0., 0.);
|
||||||
|
}
|
||||||
Msg::WindowScrolled => {
|
Msg::WindowScrolled => {
|
||||||
info!("WindowScrolled");
|
// TODO: model.content_el doesn't go to None like it should when a DOM is recreated and the refrenced element goes away
|
||||||
if let Some(el) = model.content_el.get() {
|
if let Some(el) = model.content_el.get() {
|
||||||
let ih = window()
|
let ih = window()
|
||||||
.inner_height()
|
.inner_height()
|
||||||
@@ -516,7 +566,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
.value_of();
|
.value_of();
|
||||||
|
|
||||||
let r = el.get_bounding_client_rect();
|
let r = el.get_bounding_client_rect();
|
||||||
info!("r {r:?} ih {ih}");
|
|
||||||
if r.height() < ih {
|
if r.height() < ih {
|
||||||
// The whole content fits in the window, no scrollbar
|
// The whole content fits in the window, no scrollbar
|
||||||
orders.send_msg(Msg::SetProgress(0.));
|
orders.send_msg(Msg::SetProgress(0.));
|
||||||
@@ -548,7 +597,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
model.read_completion_ratio = ratio;
|
model.read_completion_ratio = ratio;
|
||||||
}
|
}
|
||||||
Msg::UpdateServerVersion(version) => {
|
Msg::UpdateServerVersion(version) => {
|
||||||
if version != model.versions.client {
|
// Only git versions contain dash, don't autoreload there
|
||||||
|
if !version.contains('-') && version != model.versions.client {
|
||||||
warn!(
|
warn!(
|
||||||
"Server ({}) and client ({}) version mismatch, reloading",
|
"Server ({}) and client ({}) version mismatch, reloading",
|
||||||
version, model.versions.client
|
version, model.versions.client
|
||||||
@@ -557,8 +607,94 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
|||||||
}
|
}
|
||||||
model.versions.server = Some(version);
|
model.versions.server = Some(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Msg::CatchupStart => {
|
||||||
|
let query = if model.query.contains("is:unread") {
|
||||||
|
model.query.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} is:unread", model.query)
|
||||||
|
};
|
||||||
|
info!("starting catchup mode w/ {}", query);
|
||||||
|
orders.send_msg(Msg::ScrollToTop);
|
||||||
|
orders.send_msg(Msg::CatchupRequest { query });
|
||||||
|
}
|
||||||
|
Msg::CatchupKeepUnread => {
|
||||||
|
orders.send_msg(Msg::CatchupNext);
|
||||||
|
}
|
||||||
|
Msg::CatchupMarkAsRead => {
|
||||||
|
if let Some(thread_id) = current_thread_id(&model.context) {
|
||||||
|
orders.send_msg(Msg::SetUnread(thread_id, false));
|
||||||
|
};
|
||||||
|
orders.send_msg(Msg::CatchupNext);
|
||||||
|
}
|
||||||
|
Msg::CatchupNext => {
|
||||||
|
orders.send_msg(Msg::ScrollToTop);
|
||||||
|
let Some(catchup) = &mut model.catchup else {
|
||||||
|
orders.send_msg(Msg::GoToSearchResults);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(thread_id) = current_thread_id(&model.context) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(idx) = catchup
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.inspect(|i| info!("i {i:?} thread_id {thread_id}"))
|
||||||
|
.position(|i| i.id == thread_id)
|
||||||
|
else {
|
||||||
|
// All items have been seen
|
||||||
|
orders.send_msg(Msg::CatchupExit);
|
||||||
|
orders.send_msg(Msg::GoToSearchResults);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
catchup.items[idx].seen = true;
|
||||||
|
if idx < catchup.items.len() - 1 {
|
||||||
|
// Reached last item
|
||||||
|
orders.request_url(urls::thread(&catchup.items[idx + 1].id));
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
orders.send_msg(Msg::CatchupExit);
|
||||||
|
orders.send_msg(Msg::GoToSearchResults);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Msg::CatchupExit => {
|
||||||
|
orders.send_msg(Msg::ScrollToTop);
|
||||||
|
model.catchup = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Msg::WebSocket(ws) => {
|
||||||
|
websocket::update(ws, &mut model.websocket, &mut orders.proxy(Msg::WebSocket));
|
||||||
|
while let Some(msg) = model.websocket.updates.pop_front() {
|
||||||
|
orders.send_msg(Msg::WebsocketMessage(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Msg::WebsocketMessage(msg) => {
|
||||||
|
match msg {
|
||||||
|
WebsocketMessage::RefreshMessages => orders.send_msg(Msg::Refresh),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_thread_id(context: &Context) -> Option<String> {
|
||||||
|
match context {
|
||||||
|
Context::ThreadResult {
|
||||||
|
thread:
|
||||||
|
ShowThreadQueryThread::EmailThread(ShowThreadQueryThreadOnEmailThread {
|
||||||
|
thread_id, ..
|
||||||
|
}),
|
||||||
|
..
|
||||||
|
} => Some(thread_id.clone()),
|
||||||
|
Context::ThreadResult {
|
||||||
|
thread:
|
||||||
|
ShowThreadQueryThread::NewsPost(ShowThreadQueryThreadOnNewsPost { thread_id, .. }),
|
||||||
|
..
|
||||||
|
} => Some(thread_id.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `Model` describes our app state.
|
// `Model` describes our app state.
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
@@ -568,6 +704,9 @@ pub struct Model {
|
|||||||
pub read_completion_ratio: f64,
|
pub read_completion_ratio: f64,
|
||||||
pub content_el: ElRef<HtmlElement>,
|
pub content_el: ElRef<HtmlElement>,
|
||||||
pub versions: Version,
|
pub versions: Version,
|
||||||
|
pub catchup: Option<Catchup>,
|
||||||
|
pub last_url: Url,
|
||||||
|
pub websocket: websocket::Model,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -604,6 +743,16 @@ pub enum Context {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct Catchup {
|
||||||
|
pub items: Vec<CatchupItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CatchupItem {
|
||||||
|
pub id: String,
|
||||||
|
pub seen: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Tag {
|
pub struct Tag {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub bg_color: String,
|
pub bg_color: String,
|
||||||
@@ -617,20 +766,22 @@ pub enum RefreshingState {
|
|||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
// `Msg` describes the different events you can modify state with.
|
// `Msg` describes the different events you can modify state with.
|
||||||
|
#[derive(strum_macros::Display)]
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Noop,
|
Noop,
|
||||||
// Tell the client to refresh its state
|
// Tell the client to refresh its state
|
||||||
Refresh,
|
Refresh,
|
||||||
// Tell the client to reload whole page from server
|
// Tell the client to reload whole page from server
|
||||||
Reload,
|
Reload,
|
||||||
// Window has changed size
|
// TODO: add GoToUrl
|
||||||
OnResize,
|
OnUrlChanged(subs::UrlChanged),
|
||||||
// Tell the server to update state
|
// Tell the server to update state
|
||||||
RefreshStart,
|
RefreshStart,
|
||||||
RefreshDone(Option<gloo_net::Error>),
|
RefreshDone(Option<gloo_net::Error>),
|
||||||
NextPage,
|
NextPage,
|
||||||
PreviousPage,
|
PreviousPage,
|
||||||
GoToSearchResults,
|
GoToSearchResults,
|
||||||
|
|
||||||
UpdateQuery(String),
|
UpdateQuery(String),
|
||||||
SearchQuery(String),
|
SearchQuery(String),
|
||||||
|
|
||||||
@@ -654,12 +805,17 @@ pub enum Msg {
|
|||||||
ShowThreadResult(
|
ShowThreadResult(
|
||||||
Result<graphql_client::Response<graphql::show_thread_query::ResponseData>, gloo_net::Error>,
|
Result<graphql_client::Response<graphql::show_thread_query::ResponseData>, gloo_net::Error>,
|
||||||
),
|
),
|
||||||
|
CatchupRequest {
|
||||||
|
query: String,
|
||||||
|
},
|
||||||
|
CatchupResult(
|
||||||
|
Result<graphql_client::Response<graphql::catchup_query::ResponseData>, gloo_net::Error>,
|
||||||
|
),
|
||||||
|
|
||||||
SelectionSetNone,
|
SelectionSetNone,
|
||||||
SelectionSetAll,
|
SelectionSetAll,
|
||||||
SelectionAddTag(String),
|
SelectionAddTag(String),
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
// TODO
|
|
||||||
SelectionRemoveTag(String),
|
SelectionRemoveTag(String),
|
||||||
SelectionMarkAsRead,
|
SelectionMarkAsRead,
|
||||||
SelectionMarkAsUnread,
|
SelectionMarkAsUnread,
|
||||||
@@ -672,7 +828,17 @@ pub enum Msg {
|
|||||||
|
|
||||||
CopyToClipboard(String),
|
CopyToClipboard(String),
|
||||||
|
|
||||||
|
ScrollToTop,
|
||||||
WindowScrolled,
|
WindowScrolled,
|
||||||
SetProgress(f64),
|
SetProgress(f64),
|
||||||
UpdateServerVersion(String),
|
UpdateServerVersion(String),
|
||||||
|
|
||||||
|
CatchupStart,
|
||||||
|
CatchupKeepUnread,
|
||||||
|
CatchupMarkAsRead,
|
||||||
|
CatchupNext,
|
||||||
|
CatchupExit,
|
||||||
|
|
||||||
|
WebSocket(websocket::Msg),
|
||||||
|
WebsocketMessage(WebsocketMessage),
|
||||||
}
|
}
|
||||||
|
|||||||
1664
web/src/view/mod.rs
1664
web/src/view/mod.rs
File diff suppressed because it is too large
Load Diff
220
web/src/websocket.rs
Normal file
220
web/src/websocket.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
use std::{collections::VecDeque, rc::Rc};
|
||||||
|
|
||||||
|
use letterbox_shared::WebsocketMessage;
|
||||||
|
use log::{error, info};
|
||||||
|
use seed::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod wasm_sockets {
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use web_sys::{CloseEvent, ErrorEvent};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct JsValue;
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ConnectionStatus {
|
||||||
|
/// Connecting to a server
|
||||||
|
Connecting,
|
||||||
|
/// Connected to a server
|
||||||
|
Connected,
|
||||||
|
/// Disconnected from a server due to an error
|
||||||
|
Error,
|
||||||
|
/// Disconnected from a server without an error
|
||||||
|
Disconnected,
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EventClient {
|
||||||
|
pub status: Rc<RefCell<ConnectionStatus>>,
|
||||||
|
}
|
||||||
|
impl EventClient {
|
||||||
|
pub fn new(_: &str) -> Result<Self, WebSocketError> {
|
||||||
|
todo!("this is a mock")
|
||||||
|
}
|
||||||
|
pub fn send_string(&self, _essage: &str) -> Result<(), JsValue> {
|
||||||
|
todo!("this is a mock")
|
||||||
|
}
|
||||||
|
pub fn set_on_error(&mut self, _: Option<Box<dyn Fn(ErrorEvent)>>) {
|
||||||
|
todo!("this is a mock")
|
||||||
|
}
|
||||||
|
pub fn set_on_connection(&mut self, _: Option<Box<dyn Fn(&EventClient)>>) {
|
||||||
|
todo!("this is a mock")
|
||||||
|
}
|
||||||
|
pub fn set_on_close(&mut self, _: Option<Box<dyn Fn(CloseEvent)>>) {
|
||||||
|
todo!("this is a mock")
|
||||||
|
}
|
||||||
|
pub fn set_on_message(&mut self, _: Option<Box<dyn Fn(&EventClient, Message)>>) {
|
||||||
|
todo!("this is a mock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
Text(String),
|
||||||
|
Binary(Vec<u8>),
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Error)]
|
||||||
|
pub enum WebSocketError {}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use wasm_sockets::{ConnectionStatus, EventClient, Message, WebSocketError};
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use wasm_sockets::{ConnectionStatus, EventClient, Message, WebSocketError};
|
||||||
|
use web_sys::CloseEvent;
|
||||||
|
|
||||||
|
/// Message from the server to the client.
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ServerMessage {
|
||||||
|
pub id: usize,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message from the client to the server.
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ClientMessage {
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
//const WS_URL: &str = "wss://9000.z.xinu.tv/api/ws";
|
||||||
|
//const WS_URL: &str = "wss://9345.z.xinu.tv/api/graphql/ws";
|
||||||
|
//const WS_URL: &str = "wss://6758.z.xinu.tv/api/ws";
|
||||||
|
|
||||||
|
// ------ ------
|
||||||
|
// Model
|
||||||
|
// ------ ------
|
||||||
|
|
||||||
|
pub struct Model {
|
||||||
|
ws_url: String,
|
||||||
|
web_socket: EventClient,
|
||||||
|
web_socket_reconnector: Option<StreamHandle>,
|
||||||
|
pub updates: VecDeque<WebsocketMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------ ------
|
||||||
|
// Init
|
||||||
|
// ------ ------
|
||||||
|
|
||||||
|
pub fn init(ws_url: &str, orders: &mut impl Orders<Msg>) -> Model {
|
||||||
|
Model {
|
||||||
|
ws_url: ws_url.to_string(),
|
||||||
|
web_socket: create_websocket(ws_url, orders).unwrap(),
|
||||||
|
web_socket_reconnector: None,
|
||||||
|
updates: VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------ ------
|
||||||
|
// Update
|
||||||
|
// ------ ------
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
WebSocketOpened,
|
||||||
|
TextMessageReceived(WebsocketMessage),
|
||||||
|
WebSocketClosed(CloseEvent),
|
||||||
|
WebSocketFailed,
|
||||||
|
ReconnectWebSocket(usize),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
SendMessage(ClientMessage),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
match msg {
|
||||||
|
Msg::WebSocketOpened => {
|
||||||
|
model.web_socket_reconnector = None;
|
||||||
|
info!("WebSocket connection is open now");
|
||||||
|
}
|
||||||
|
Msg::TextMessageReceived(msg) => {
|
||||||
|
model.updates.push_back(msg);
|
||||||
|
}
|
||||||
|
Msg::WebSocketClosed(close_event) => {
|
||||||
|
info!(
|
||||||
|
r#"==================
|
||||||
|
WebSocket connection was closed:
|
||||||
|
Clean: {0}
|
||||||
|
Code: {1}
|
||||||
|
Reason: {2}
|
||||||
|
=================="#,
|
||||||
|
close_event.was_clean(),
|
||||||
|
close_event.code(),
|
||||||
|
close_event.reason()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chrome doesn't invoke `on_error` when the connection is lost.
|
||||||
|
if !close_event.was_clean() && model.web_socket_reconnector.is_none() {
|
||||||
|
model.web_socket_reconnector = Some(
|
||||||
|
orders.stream_with_handle(streams::backoff(None, Msg::ReconnectWebSocket)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Msg::WebSocketFailed => {
|
||||||
|
info!("WebSocket failed");
|
||||||
|
if model.web_socket_reconnector.is_none() {
|
||||||
|
model.web_socket_reconnector = Some(
|
||||||
|
orders.stream_with_handle(streams::backoff(None, Msg::ReconnectWebSocket)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Msg::ReconnectWebSocket(retries) => {
|
||||||
|
info!("Reconnect attempt: {}", retries);
|
||||||
|
model.web_socket = create_websocket(&model.ws_url, orders).unwrap();
|
||||||
|
}
|
||||||
|
Msg::SendMessage(msg) => {
|
||||||
|
let txt = serde_json::to_string(&msg).unwrap();
|
||||||
|
model.web_socket.send_string(&txt).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_websocket(url: &str, orders: &impl Orders<Msg>) -> Result<EventClient, WebSocketError> {
|
||||||
|
let msg_sender = orders.msg_sender();
|
||||||
|
|
||||||
|
let mut client = EventClient::new(url)?;
|
||||||
|
|
||||||
|
client.set_on_error(Some(Box::new(|error| {
|
||||||
|
gloo_console::error!("WS: ", error);
|
||||||
|
})));
|
||||||
|
|
||||||
|
let send = msg_sender.clone();
|
||||||
|
client.set_on_connection(Some(Box::new(move |client: &EventClient| {
|
||||||
|
info!("{:#?}", client.status);
|
||||||
|
let msg = match *client.status.borrow() {
|
||||||
|
ConnectionStatus::Connecting => {
|
||||||
|
info!("Connecting...");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ConnectionStatus::Connected => Some(Msg::WebSocketOpened),
|
||||||
|
ConnectionStatus::Error => Some(Msg::WebSocketFailed),
|
||||||
|
ConnectionStatus::Disconnected => {
|
||||||
|
info!("Disconnected");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
send(msg);
|
||||||
|
})));
|
||||||
|
|
||||||
|
let send = msg_sender.clone();
|
||||||
|
client.set_on_close(Some(Box::new(move |ev| {
|
||||||
|
info!("WS: Connection closed");
|
||||||
|
send(Some(Msg::WebSocketClosed(ev)));
|
||||||
|
})));
|
||||||
|
|
||||||
|
let send = msg_sender.clone();
|
||||||
|
client.set_on_message(Some(Box::new(move |_: &EventClient, msg: Message| {
|
||||||
|
decode_message(msg, Rc::clone(&send))
|
||||||
|
})));
|
||||||
|
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_message(message: Message, msg_sender: Rc<dyn Fn(Option<Msg>)>) {
|
||||||
|
match message {
|
||||||
|
Message::Text(txt) => {
|
||||||
|
let msg: WebsocketMessage = serde_json::from_str(&txt).unwrap_or_else(|e| {
|
||||||
|
panic!("failed to parse json into WebsocketMessage: {e}\n'{txt}'")
|
||||||
|
});
|
||||||
|
msg_sender(Some(Msg::TextMessageReceived(msg)));
|
||||||
|
}
|
||||||
|
m => error!("unexpected message type received of {m:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
84
web/static/overrides.css
Normal file
84
web/static/overrides.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
html {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content a,
|
||||||
|
.news-post a {
|
||||||
|
color: var(--color-link) !important;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content br,
|
||||||
|
.news-post br {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1em;
|
||||||
|
content: " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content h1,
|
||||||
|
.mail-thread .content h2,
|
||||||
|
.mail-thread .content h3,
|
||||||
|
.mail-thread .content h4,
|
||||||
|
.news-post h1,
|
||||||
|
.news-post h2,
|
||||||
|
.news-post h3,
|
||||||
|
.news-post h4 {
|
||||||
|
margin-top: 1em !important;
|
||||||
|
margin-bottom: 1em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content p,
|
||||||
|
.news-post p {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content pre,
|
||||||
|
.news-post pre {
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: #eee !important;
|
||||||
|
padding: 0.5em;
|
||||||
|
white-space: break-spaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content code,
|
||||||
|
.news-post code {
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: break-spaces;
|
||||||
|
background-color: #eee !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content blockquote {
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 2px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content ol,
|
||||||
|
.mail-thread .content ul {
|
||||||
|
margin-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content .noreply-news-bloomberg-com a {
|
||||||
|
background-color: initial !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-thread .content .noreply-news-bloomberg-com h2 {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hackaday figures have unreadable black on dark grey */
|
||||||
|
.news-post figcaption.wp-caption-text {
|
||||||
|
background-color: initial !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-post.site-nautilus .article-ad,
|
||||||
|
.news-post.site-nautilus .primis-ad {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.news-post.site-slashdot .story-byline {
|
||||||
|
display: block !important;
|
||||||
|
height: initial !important;
|
||||||
|
overflow: auto !important;
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
42
web/static/vars.css
Normal file
42
web/static/vars.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
:root {
|
||||||
|
--active-brightness: 0.85;
|
||||||
|
--border-radius: 5px;
|
||||||
|
--box-shadow: 2px 2px 10px;
|
||||||
|
--color-accent: #118bee15;
|
||||||
|
--color-bg: #fff;
|
||||||
|
--color-bg-secondary: #e9e9e9;
|
||||||
|
--color-link: #118bee;
|
||||||
|
--color-secondary: #920de9;
|
||||||
|
--color-secondary-accent: #920de90b;
|
||||||
|
--color-shadow: #f4f4f4;
|
||||||
|
--color-table: #118bee;
|
||||||
|
--color-text: #000;
|
||||||
|
--color-text-secondary: #999;
|
||||||
|
--color-scrollbar: #cacae8;
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||||
|
--hover-brightness: 1.2;
|
||||||
|
--justify-important: center;
|
||||||
|
--justify-normal: left;
|
||||||
|
--line-height: 1.5;
|
||||||
|
/*
|
||||||
|
--width-card: 285px;
|
||||||
|
--width-card-medium: 460px;
|
||||||
|
--width-card-wide: 800px;
|
||||||
|
*/
|
||||||
|
--width-content: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root[color-mode="user"] {
|
||||||
|
--color-accent: #0097fc4f;
|
||||||
|
--color-bg: #333;
|
||||||
|
--color-bg-secondary: #555;
|
||||||
|
--color-link: #0097fc;
|
||||||
|
--color-secondary: #e20de9;
|
||||||
|
--color-secondary-accent: #e20de94f;
|
||||||
|
--color-shadow: #bbbbbb20;
|
||||||
|
--color-table: #0097fc;
|
||||||
|
--color-text: #f7f7f7;
|
||||||
|
--color-text-secondary: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user