Compare commits

..

No commits in common. "master" and "letterbox-shared-v0.8.1" have entirely different histories.

73 changed files with 3439 additions and 9074 deletions

View File

@ -1,4 +1,4 @@
on: [push] on: [push, pull_request]
name: Continuous integration name: Continuous integration
@ -26,7 +26,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1 - uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
toolchain: nightly toolchain: stable
target: wasm32-unknown-unknown target: wasm32-unknown-unknown
- run: cargo install trunk - run: cargo install trunk
- run: cd web; trunk build - run: cd web; trunk build

View File

@ -1,40 +0,0 @@
# Copilot/AI Agent Instructions for Letterbox
## Project Overview
- **Letterbox** is a Rust monorepo for a mail/newsreader system with a web frontend and a Rocket/GraphQL backend.
- Major crates: `server` (backend, Rocket+async-graphql), `web` (Seed-based WASM frontend), `notmuch` (mail integration), `shared` (common types), `procmail2notmuch` (migration/utility).
- Data flows: Email/news data is indexed and queried via the backend, exposed to the frontend via GraphQL. SQLx/Postgres is used for persistence. Notmuch and custom SQL are both used for mail storage/search.
## Key Workflows
- **Development**: Use `dev.sh` to launch a tmux session with live-reloading for both frontend (`trunk serve`) and backend (`cargo watch ... run`).
- **Build/Release**: Use `just patch|minor|major` for versioned releases (runs SQLx prepare, bumps versions, pushes). `Makefile`'s `release` target does similar steps.
- **Frontend**: In `web/`, use `cargo make serve` and `cargo make watch` for local dev. See `web/README.md` for Seed-specific details.
- **Backend**: In `server/`, run with `cargo run` or via the tmux/dev.sh workflow. SQL migrations are in `server/migrations/`.
## Project Conventions & Patterns
- **GraphQL**: All API boundaries are defined in `server/src/graphql.rs`. Use the `Query`, `Mutation`, and `Subscription` roots. Types are defined with `async-graphql` derive macros.
- **HTML Sanitization**: See `server/src/lib.rs` for custom HTML/CSS sanitization and transformation logic (e.g., `Transformer` trait, `sanitize_html`).
- **Tag/Query Parsing**: The `Query` struct in `server/src/lib.rs` parses user queries into filters for notmuch/newsreader/tantivy.
- **Shared Types**: Use the `shared` crate for types and helpers shared between frontend and backend.
- **Custom SQL**: Raw SQL queries are in `server/sql/`. Use these for complex queries not handled by SQLx macros.
- **Feature Flags**: The `tantivy` feature enables full-text search via Tantivy. Check for `#[cfg(feature = "tantivy")]` in backend code.
## Integration Points
- **Notmuch**: Integrated via the `notmuch` crate for mail indexing/search.
- **Postgres**: Used for newsreader and other persistent data (see `server/migrations/`).
- **GraphQL**: All client-server communication is via GraphQL endpoints defined in the backend.
- **Seed/Trunk**: Frontend is built with Seed (Rust/WASM) and served via Trunk.
## Examples
- To add a new GraphQL query, update `server/src/graphql.rs` and expose it in the `QueryRoot`.
- To add a new frontend page, add a module in `web/src/` and register it in the Seed app's router.
- To run the full dev environment: `./dev.sh` (requires tmux, trunk, cargo-watch, etc.).
## References
- See `web/README.md` for frontend/Seed workflow details.
- See `Justfile` and `Makefile` for release/versioning automation.
- See `server/src/lib.rs` and `server/src/graphql.rs` for backend architecture and conventions.
- See `server/sql/` for custom SQL queries.
---
If any conventions or workflows are unclear, please ask for clarification or check the referenced files for examples.

3918
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ authors = ["Bill Thiede <git@xinu.tv>"]
edition = "2021" edition = "2021"
license = "UNLICENSED" license = "UNLICENSED"
publish = ["xinu"] publish = ["xinu"]
version = "0.17.55" version = "0.8.1"
repository = "https://git.z.xinu.tv/wathiede/letterbox" repository = "https://git.z.xinu.tv/wathiede/letterbox"
[profile.dev] [profile.dev]

View File

@ -1,6 +1,3 @@
export CARGO_INCREMENTAL := "0"
export RUSTFLAGS := "-D warnings"
default: default:
@echo "Run: just patch|minor|major" @echo "Run: just patch|minor|major"
@ -11,9 +8,6 @@ patch: (_release "patch")
sqlx-prepare: sqlx-prepare:
cd server; cargo sqlx prepare && git add .sqlx; git commit -m "cargo sqlx prepare" .sqlx || true cd server; cargo sqlx prepare && git add .sqlx; git commit -m "cargo sqlx prepare" .sqlx || true
pull:
git pull
_release level: sqlx-prepare
_release level: pull sqlx-prepare
cargo-release release -x {{ level }} --workspace --no-confirm --registry=xinu cargo-release release -x {{ level }} --workspace --no-confirm --registry=xinu

2
dev.sh
View File

@ -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 -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 send-keys "cd server; cargo watch -c -x run -w ../.git -w ../shared -w ../notmuch -w ./" C-m
tmux attach -d -t letterbox-dev tmux attach -d -t letterbox-dev

View File

@ -11,14 +11,14 @@ version.workspace = true
[dependencies] [dependencies]
log = "0.4.27" log = "0.4.14"
mailparse = "0.16.1" mailparse = "0.16.0"
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 = "2.0.12" thiserror = "2.0.0"
tracing = "0.1.41" tracing = "0.1.41"
[dev-dependencies] [dev-dependencies]
itertools = "0.14.0" itertools = "0.14.0"
pretty_assertions = "1" pretty_assertions = "1"
rayon = "1.10" rayon = "1.5"

View File

@ -214,8 +214,9 @@ use std::{
process::Command, process::Command,
}; };
use log::{error, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, info, instrument, warn}; use tracing::instrument;
/// # Number of seconds since the Epoch /// # Number of seconds since the Epoch
pub type UnixTime = isize; pub type UnixTime = isize;
@ -270,12 +271,6 @@ 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,
} }
@ -469,7 +464,7 @@ pub enum NotmuchError {
MailParseError(#[from] mailparse::MailParseError), MailParseError(#[from] mailparse::MailParseError),
} }
#[derive(Clone, Default)] #[derive(Default)]
pub struct Notmuch { pub struct Notmuch {
config_path: Option<PathBuf>, config_path: Option<PathBuf>,
} }
@ -502,28 +497,15 @@ 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.tags_add(tag, &[search_term]) self.run_notmuch(["tag", &format!("+{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.tags_remove(tag, &[search_term]) self.run_notmuch(["tag", &format!("-{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(())
} }
@ -610,11 +592,6 @@ 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)
} }
@ -651,29 +628,17 @@ impl Notmuch {
let ts: ThreadSet = serde::de::Deserialize::deserialize(&mut deserializer)?; let ts: ThreadSet = serde::de::Deserialize::deserialize(&mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut r = HashMap::new(); let mut r = HashMap::new();
fn collect_from_thread_node( for t in ts.0 {
r: &mut HashMap<String, usize>, for tn in t.0 {
tn: &ThreadNode, let Some(msg) = tn.0 else {
) -> Result<(), NotmuchError> { continue;
let Some(msg) = &tn.0 else {
return Ok(());
}; };
let mut addrs = vec![]; let mut addrs = vec![];
let hdr = &msg.headers.to; 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 { if let Some(to) = hdr {
addrs.push(to); addrs.push(to);
}; };
}; let hdr = msg.headers.cc;
};
let hdr = &msg.headers.cc;
if let Some(cc) = hdr { if let Some(cc) = hdr {
addrs.push(cc); addrs.push(cc);
}; };
@ -686,20 +651,11 @@ impl Notmuch {
return; return;
}; };
let addr = &si.addr; let addr = &si.addr;
if addr == "couchmoney@gmail.com" || addr.ends_with("@xinu.tv") { if addr == "couchmoney@gmail.com" || addr.ends_with("@xinu.tv") {
*r.entry(addr.to_lowercase()).or_default() += 1; *r.entry(addr.clone()).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) Ok(r)
@ -717,13 +673,6 @@ impl Notmuch {
cmd.args(args); cmd.args(args);
info!("{:?}", &cmd); info!("{:?}", &cmd);
let out = cmd.output()?; let out = cmd.output()?;
if !out.stderr.is_empty() {
warn!(
"{:?}: STDERR:\n{}",
&cmd,
String::from_utf8_lossy(&out.stderr)
);
}
Ok(out.stdout) Ok(out.stdout)
} }
} }

View File

@ -11,10 +11,4 @@ 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.98" anyhow = "1.0.69"
clap = { version = "4.5.37", features = ["derive", "env"] }
letterbox-notmuch = { version = "0.17", registry = "xinu" }
letterbox-shared = { version = "0.17", 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"] }

View File

@ -1,105 +1,151 @@
use std::{collections::HashMap, io::Write}; use std::{convert::Infallible, io::Write, str::FromStr};
use clap::{Parser, Subcommand}; #[derive(Debug, Default)]
use letterbox_shared::{cleanup_match, Match, MatchType, Rule}; enum MatchType {
use sqlx::{types::Json, PgPool}; From,
Sender,
#[derive(Debug, Subcommand)] To,
enum Mode { Cc,
Debug, Subject,
Notmuchrc, List,
LoadSql { DeliveredTo,
#[arg(short, long)] XForwardedTo,
dsn: String, ReplyTo,
}, XOriginalTo,
XSpam,
Body,
#[default]
Unknown,
}
#[derive(Debug, Default)]
struct Match {
match_type: MatchType,
needle: String,
} }
/// Simple program to greet a person #[derive(Debug, Default)]
#[derive(Parser, Debug)] struct Rule {
#[command(version, about, long_about = None)] matches: Vec<Match>,
struct Args { tags: Vec<String>,
#[arg(short, long, default_value = "/home/wathiede/dotfiles/procmailrc")]
input: String,
#[command(subcommand)]
mode: Mode,
} }
#[tokio::main] fn unescape(s: &str) -> String {
async fn main() -> anyhow::Result<()> { s.replace('\\', "")
let args = Args::parse(); }
let mut rules = Vec::new();
let mut cur_rule = Rule::default(); fn cleanup_match(prefix: &str, s: &str) -> String {
for l in std::fs::read_to_string(args.input)?.lines() { unescape(&s[prefix.len()..]).replace(".*", "")
let l = if let Some(idx) = l.find('#') { }
&l[..idx]
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 { } else {
l unreachable!("needle: '{needle}'")
} }
.trim(); } else {
if l.is_empty() { return Ok(Match {
continue; match_type: MatchType::Body,
needle: cleanup_match("", &needle),
});
} }
if l.find('=').is_some() {
// Probably a variable assignment, skip line
continue;
}
let first = l.chars().nth(0).unwrap_or(' ');
match first {
':' => {
// 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
let m: Match = l.parse()?;
cur_rule.matches.push(m);
}
'.' => {
// delivery to folder
cur_rule.tag = cleanup_match(
"",
&l.replace('.', "/")
.replace(' ', "")
.trim_matches('/')
.to_string(),
);
rules.push(cur_rule);
cur_rule = Rule::default();
}
'/' => cur_rule = Rule::default(), // Ex. /dev/null
'|' => cur_rule = Rule::default(), // external command
'$' => {
// TODO(wathiede): tag messages with no other tag as 'inbox'
cur_rule.tag = cleanup_match("", "inbox");
rules.push(cur_rule);
cur_rule = Rule::default();
} // variable, should only be $DEFAULT in my config
_ => panic!("Unhandled first character '{}'\nLine: {}", first, l),
}
}
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}");
} }
} }
@ -109,7 +155,7 @@ fn notmuch_from_rules<W: Write>(mut w: W, rules: &[Rule]) -> anyhow::Result<()>
let mut lines = Vec::new(); let mut lines = Vec::new();
for r in rules { for r in rules {
for m in &r.matches { for m in &r.matches {
let t = &r.tag; for t in &r.tags {
if let MatchType::Unknown = m.match_type { if let MatchType::Unknown = m.match_type {
eprintln!("rule has unknown match {:?}", r); eprintln!("rule has unknown match {:?}", r);
continue; continue;
@ -122,7 +168,7 @@ fn notmuch_from_rules<W: Write>(mut w: W, rules: &[Rule]) -> anyhow::Result<()>
MatchType::To => "to:", MatchType::To => "to:",
MatchType::Cc => "to:", MatchType::Cc => "to:",
MatchType::Subject => "subject:", MatchType::Subject => "subject:",
MatchType::ListId => "List-ID:", MatchType::List => "List-ID:",
MatchType::Body => "", MatchType::Body => "",
// TODO(wathiede): these will probably require adding fields to notmuch // TODO(wathiede): these will probably require adding fields to notmuch
// index. Handle them later. // index. Handle them later.
@ -146,6 +192,7 @@ fn notmuch_from_rules<W: Write>(mut w: W, rules: &[Rule]) -> anyhow::Result<()>
)); ));
} }
} }
}
lines.sort(); lines.sort();
for l in lines { for l in lines {
writeln!(w, "{l}")?; writeln!(w, "{l}")?;
@ -153,25 +200,56 @@ fn notmuch_from_rules<W: Write>(mut w: W, rules: &[Rule]) -> anyhow::Result<()>
Ok(()) Ok(())
} }
async fn load_sql(dsn: &str, rules: &[Rule]) -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let pool = PgPool::connect(dsn).await?; let input = "/home/wathiede/dotfiles/procmailrc";
println!("clearing email_rule table"); let mut rules = Vec::new();
sqlx::query!("DELETE FROM email_rule") let mut cur_rule = Rule::default();
.execute(&pool) for l in std::fs::read_to_string(input)?.lines() {
.await?; let l = if let Some(idx) = l.find('#') {
&l[..idx]
for (order, rule) in rules.iter().enumerate() { } else {
println!("inserting {order}: {rule:?}"); l
sqlx::query!(
r#"
INSERT INTO email_rule (sort_order, rule)
VALUES ($1, $2)
"#,
order as i32,
Json(rule) as _
)
.execute(&pool)
.await?;
} }
.trim();
if l.is_empty() {
continue;
}
if l.find('=').is_some() {
// Probably a variable assignment, skip line
continue;
}
let first = l.chars().nth(0).unwrap_or(' ');
match first {
':' => {
// start of rule
}
'*' => {
// add to current rule
let m: Match = l.parse()?;
cur_rule.matches.push(m);
}
'.' => {
// delivery to folder
cur_rule.tags.push(cleanup_match(
"",
&l.replace('.', "/")
.replace(' ', "")
.trim_matches('/')
.to_string(),
));
rules.push(cur_rule);
cur_rule = Rule::default();
}
'|' => cur_rule = Rule::default(), // external command
'$' => {
// TODO(wathiede): tag messages with no other tag as 'inbox'
cur_rule.tags.push(cleanup_match("", "inbox"));
rules.push(cur_rule);
cur_rule = Rule::default();
} // variable, should only be $DEFAULT in my config
_ => panic!("Unhandled first character '{}' {}", first, l),
}
}
notmuch_from_rules(std::io::stdout(), &rules)?;
Ok(()) Ok(())
} }

View File

@ -3,11 +3,4 @@
"extends": [ "extends": [
"config:recommended" "config:recommended"
] ]
,
"packageRules": [
{
"matchPackageNames": ["wasm-bindgen"],
"enabled": false
}
]
} }

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\nSELECT\n url\nFROM email_photo ep\nJOIN email_address ea\nON ep.id = ea.email_photo_id\nWHERE\n address = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "url",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "126e16a4675e8d79f330b235f9e1b8614ab1e1526e4e69691c5ebc70d54a42ef"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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 1000;\n", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -28,5 +28,5 @@
true true
] ]
}, },
"hash": "cf369e3d5547f400cb54004dd03783ef6998a000aec91c50a79405dcf1c53b17" "hash": "3d271b404f06497a5dcde68cf6bf07291d70fa56058ea736ac24e91d33050c04"
} }

View File

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM snooze WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "77f79f981a9736d18ffd4b87d3aec34d6a048162154a3aba833370c58a860795"
}

View File

@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\nSELECT id, message_id\nFROM snooze\nWHERE wake < NOW();\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "message_id",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "c8383663124a5cc5912b54553f18f7064d33087ebfdf3c0c1c43cbe6d3577084"
}

View File

@ -0,0 +1,22 @@
{
"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"
}

View File

@ -0,0 +1,24 @@
{
"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"
}

View File

@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO snooze (message_id, wake)\n VALUES ($1, $2)\n ON CONFLICT (message_id) DO UPDATE\n SET wake = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "effd0d0d91e6ad84546f7177f1fd39d4fad736b471eb5e55fd5ac74f7adff664"
}

View File

@ -12,55 +12,47 @@ 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]
chrono-tz = "0.10" ammonia = "4.0.0"
html2text = "0.16" anyhow = "1.0.79"
ammonia = "4.1.0" async-graphql = { version = "7", features = ["log"] }
anyhow = "1.0.98" async-graphql-rocket = "7"
askama = { version = "0.14.0", features = ["derive"] } async-trait = "0.1.81"
async-graphql = { version = "7", features = ["log", "chrono"] } build-info = "0.0.39"
async-graphql-axum = "7.0.16" cacher = { version = "0.1.0", registry = "xinu" }
async-trait = "0.1.88" chrono = "0.4.39"
axum = { version = "0.8.3", features = ["ws"] } clap = { version = "4.5.23", features = ["derive"] }
axum-macros = "0.5.0" css-inline = "0.14.0"
build-info = "0.0.42"
cacher = { version = "0.2.0", registry = "xinu" }
chrono = "0.4.40"
clap = { version = "4.5.37", features = ["derive"] }
css-inline = "0.18.0"
flate2 = "1.1.2"
futures = "0.3.31" futures = "0.3.31"
headers = "0.4.0"
html-escape = "0.2.13" html-escape = "0.2.13"
ical = "0.11"
letterbox-notmuch = { path = "../notmuch", version = "0.17", registry = "xinu" }
letterbox-shared = { path = "../shared", version = "0.17", registry = "xinu" }
linkify = "0.10.0" linkify = "0.10.0"
lol_html = "2.3.0" log = "0.4.17"
mailparse = "0.16.1" lol_html = "2.0.0"
mailparse = "0.16.0"
maplit = "1.0.2" maplit = "1.0.2"
memmap = "0.7.0" memmap = "0.7.0"
quick-xml = { version = "0.38.1", features = ["serialize"] } opentelemetry = "0.28.0"
regex = "1.11.1" regex = "1.11.1"
reqwest = { version = "0.12.15", features = ["blocking"] } reqwest = { version = "0.12.7", features = ["blocking"] }
scraper = "0.25.0" 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.22.0"
sqlx = { version = "0.8.5", features = ["postgres", "runtime-tokio", "chrono"] } serde = { version = "1.0.147", features = ["derive"] }
tantivy = { version = "0.25.0", optional = true } serde_json = "1.0.87"
thiserror = "2.0.12" sqlx = { version = "0.8.2", features = ["postgres", "runtime-tokio", "time"] }
tokio = "1.44.2" tantivy = { version = "0.22.0", optional = true }
tower-http = { version = "0.6.2", features = ["trace"] } thiserror = "2.0.0"
tokio = "1.26.0"
tracing = "0.1.41" tracing = "0.1.41"
url = "2.5.4" url = "2.5.2"
urlencoding = "2.1.3" urlencoding = "2.1.3"
#xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
#xtracing = { path = "../../xtracing" } #xtracing = { path = "../../xtracing" }
xtracing = { version = "0.3.2", registry = "xinu" } #xtracing = { git = "http://git-private.h.xinu.tv/wathiede/xtracing.git" }
zip = "6.0.0" xtracing = { version = "0.2.0", registry = "xinu" }
letterbox-notmuch = { version = "0.8.1", path = "../notmuch", registry = "xinu" }
letterbox-shared = { version = "0.8.1", path = "../shared", registry = "xinu" }
[build-dependencies] [build-dependencies]
build-info-build = "0.0.42" build-info-build = "0.0.39"
[features] [features]
#default = [ "tantivy" ] #default = [ "tantivy" ]

View File

@ -5,6 +5,7 @@ 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"

View File

@ -2,5 +2,4 @@ fn main() {
// Calling `build_info_build::build_script` collects all data and makes it available to `build_info::build_info!` // Calling `build_info_build::build_script` collects all data and makes it available to `build_info::build_info!`
// and `build_info::format!` in the main program. // and `build_info::format!` in the main program.
build_info_build::build_script(); build_info_build::build_script();
println!("cargo:rerun-if-changed=templates");
} }

View File

@ -1,20 +0,0 @@
-- 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='
)
;

View File

@ -1,3 +0,0 @@
DROP TABLE IF NOT EXISTS email_rule;
-- Add down migration script here

View File

@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS email_rule (
id integer NOT NULL GENERATED ALWAYS AS IDENTITY,
sort_order integer NOT NULL,
rule jsonb NOT NULL
);

View File

@ -1,2 +0,0 @@
-- Add down migration script here
ALTER TABLE feed DROP COLUMN IF EXISTS disabled;

View File

@ -1,2 +0,0 @@
-- Add up migration script here
ALTER TABLE feed ADD disabled boolean;

View File

@ -1,2 +0,0 @@
-- Add down migration script here
DROP TABLE IF EXISTS snooze;

View File

@ -1,6 +0,0 @@
-- Add up migration script here
CREATE TABLE IF NOT EXISTS snooze (
id integer NOT NULL GENERATED ALWAYS AS IDENTITY,
message_id text NOT NULL UNIQUE,
wake timestamptz NOT NULL
);

View File

@ -1 +0,0 @@
SELECT rule as "rule: Json<Rule>" FROM email_rule ORDER BY sort_order

View File

@ -10,4 +10,4 @@ WHERE
-- TODO remove AND link ~ '^<' -- TODO remove AND link ~ '^<'
ORDER BY ORDER BY
ROW_NUMBER() OVER (PARTITION BY site ORDER BY date DESC) ROW_NUMBER() OVER (PARTITION BY site ORDER BY date DESC)
LIMIT 1000; LIMIT 100;

View File

@ -1 +0,0 @@
SELECT url FROM email_photo ep JOIN email_address ea ON ep.id = ea.email_photo_id WHERE address = $1

View File

@ -0,0 +1,22 @@
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(())
}

View File

@ -1,101 +1,114 @@
// 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)]
use std::{error::Error, net::SocketAddr, sync::Arc, time::Duration}; #[macro_use]
extern crate rocket;
use std::{error::Error, io::Cursor, str::FromStr};
use async_graphql::{extensions, http::GraphiQLSource, Schema}; use async_graphql::{extensions, http::GraphiQLSource, EmptySubscription, Schema};
use async_graphql_axum::{GraphQL, GraphQLSubscription}; use async_graphql_rocket::{GraphQLQuery, GraphQLRequest, GraphQLResponse};
//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 cacher::FilesystemCacher;
use clap::Parser; use letterbox_notmuch::{Notmuch, NotmuchError, ThreadSet};
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::{
graphql::{compute_catchup_ids, Attachment, MutationRoot, QueryRoot, SubscriptionRoot}, config::Config,
nm::{attachment_bytes, cid_attachment_bytes, label_unprocessed}, error::ServerError,
ws::ConnectionTracker, graphql::{Attachment, GraphqlSchema, Mutation, QueryRoot},
nm::{attachment_bytes, cid_attachment_bytes},
}; };
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};
// Make our own error that wraps `ServerError`. #[get("/show/<query>/pretty")]
struct AppError(letterbox_server::ServerError); async fn show_pretty(
nm: &State<Notmuch>,
query: &str,
) -> Result<Json<ThreadSet>, Debug<ServerError>> {
let query = urlencoding::decode(query).map_err(|e| ServerError::from(NotmuchError::from(e)))?;
let res = nm.show(&query).map_err(ServerError::from)?;
Ok(Json(res))
}
// Tell axum how to convert `AppError` into a response. #[get("/show/<query>")]
impl IntoResponse for AppError { async fn show(nm: &State<Notmuch>, query: &str) -> Result<Json<ThreadSet>, Debug<NotmuchError>> {
fn into_response(self) -> Response { let query = urlencoding::decode(query).map_err(NotmuchError::from)?;
( let res = nm.show(&query)?;
StatusCode::INTERNAL_SERVER_ERROR, Ok(Json(res))
format!("Something went wrong: {}", self.0), }
)
.into_response() 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);
} }
} }
// This enables using `?` on functions that return `Result<_, letterbox_server::Error>` to turn them into resp.sized_body(self.0.bytes.len(), Cursor::new(self.0.bytes))
// `Result<_, AppError>`. That way you don't need to do that manually. .ok()
impl<E> From<E> for AppError
where
E: Into<letterbox_server::ServerError>,
{
fn from(err: E) -> Self {
Self(err.into())
} }
} }
fn inline_attachment_response(attachment: Attachment) -> impl IntoResponse { struct DownloadAttachmentResponder(Attachment);
info!("attachment filename {:?}", attachment.filename);
let mut hdr_map = headers::HeaderMap::new(); impl<'r, 'o: 'r> Responder<'r, 'o> for DownloadAttachmentResponder {
if let Some(filename) = attachment.filename { fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'o> {
hdr_map.insert( let mut resp = Response::build();
header::CONTENT_DISPOSITION, if let Some(filename) = self.0.filename {
format!(r#"inline; filename="{}""#, filename) resp.header(Header::new(
.parse() "Content-Disposition",
.unwrap(), format!(r#"attachment; filename="{}""#, filename),
); ));
} }
if let Some(ct) = attachment.content_type { if let Some(content_type) = self.0.content_type {
hdr_map.insert(header::CONTENT_TYPE, ct.parse().unwrap()); 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()
} }
info!("hdr_map {hdr_map:?}");
(hdr_map, attachment.bytes).into_response()
} }
fn download_attachment_response(attachment: Attachment) -> impl IntoResponse { #[get("/cid/<id>/<cid>")]
info!("attachment filename {:?}", attachment.filename); async fn view_cid(
let mut hdr_map = headers::HeaderMap::new(); nm: &State<Notmuch>,
if let Some(filename) = attachment.filename { id: &str,
hdr_map.insert( cid: &str,
header::CONTENT_DISPOSITION, ) -> Result<InlineAttachmentResponder, Debug<ServerError>> {
format!(r#"attachment; filename="{}""#, filename) let mid = if id.starts_with("id:") {
.parse() id.to_string()
.unwrap(), } else {
); format!("id:{}", id)
} };
if let Some(ct) = attachment.content_type { info!("view cid attachment {mid} {cid}");
hdr_map.insert(header::CONTENT_TYPE, ct.parse().unwrap()); let attachment = cid_attachment_bytes(nm, &mid, &cid)?;
} Ok(InlineAttachmentResponder(attachment))
info!("hdr_map {hdr_map:?}");
(hdr_map, attachment.bytes).into_response()
} }
#[axum_macros::debug_handler] #[get("/view/attachment/<id>/<idx>/<_>")]
async fn view_attachment( async fn view_attachment(
State(AppState { nm, .. }): State<AppState>, nm: &State<Notmuch>,
extract::Path((id, idx, _)): extract::Path<(String, String, String)>, id: &str,
) -> Result<impl IntoResponse, AppError> { idx: &str,
) -> 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 {
@ -106,239 +119,122 @@ 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(inline_attachment_response(attachment)) Ok(InlineAttachmentResponder(attachment))
} }
#[get("/download/attachment/<id>/<idx>/<_>")]
async fn download_attachment( async fn download_attachment(
State(AppState { nm, .. }): State<AppState>, nm: &State<Notmuch>,
extract::Path((id, idx, _)): extract::Path<(String, String, String)>, id: &str,
) -> Result<impl IntoResponse, AppError> { idx: &str,
) -> 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 {
format!("id:{}", id) format!("id:{}", id)
}; };
info!("download attachment message id '{mid}' idx '{idx}'"); info!("download attachment {mid} {idx}");
let idx: Vec<_> = idx let idx: Vec<_> = idx
.split('.') .split('.')
.filter(|s| !s.is_empty())
.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(download_attachment_response(attachment)) Ok(DownloadAttachmentResponder(attachment))
} }
async fn view_cid( #[get("/original/<id>")]
State(AppState { nm, .. }): State<AppState>, async fn original(
extract::Path((id, cid)): extract::Path<(String, String)>, nm: &State<Notmuch>,
) -> Result<impl IntoResponse, AppError> { id: &str,
) -> 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)
}; };
info!("view cid attachment {mid} {cid}"); let res = nm.show_original(&mid)?;
let attachment = cid_attachment_bytes(&nm, &mid, &cid)?; Ok((ContentType::Plain, res))
Ok(inline_attachment_response(attachment))
} }
// TODO make this work with gitea message ids like `wathiede/letterbox/pulls/91@git.z.xinu.tv` #[rocket::get("/")]
async fn view_original( fn graphiql() -> content::RawHtml<String> {
State(AppState { nm, .. }): State<AppState>, content::RawHtml(GraphiQLSource::build().endpoint("/api/graphql").finish())
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())
} }
async fn graphiql() -> impl IntoResponse { #[rocket::get("/graphql?<query..>")]
response::Html( async fn graphql_query(schema: &State<GraphqlSchema>, query: GraphQLQuery) -> GraphQLResponse {
GraphiQLSource::build() query.execute(schema.inner()).await
.endpoint("/api/graphql/")
.subscription_endpoint("/api/graphql/ws")
.finish(),
)
} }
async fn start_ws( #[rocket::post("/graphql", data = "<request>", format = "application/json")]
ws: WebSocketUpgrade, async fn graphql_request(
ConnectInfo(addr): ConnectInfo<SocketAddr>, schema: &State<GraphqlSchema>,
State(AppState { request: GraphQLRequest,
connection_tracker, .. ) -> GraphQLResponse {
}): State<AppState>, request.execute(schema.inner()).await
) -> impl IntoResponse {
info!("intiating websocket connection for {addr}");
ws.on_upgrade(async move |socket| connection_tracker.lock().await.add_peer(socket, addr).await)
} }
#[derive(Debug, Deserialize)] #[rocket::main]
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: {}", letterbox_shared::build_version(bi)); info!("Build Info: {}", letterbox_shared::build_version(bi));
if !std::fs::exists(&cli.slurp_cache_path)? { let allowed_origins = AllowedOrigins::all();
info!("Creating slurp cache @ '{}'", &cli.slurp_cache_path); let cors = rocket_cors::CorsOptions {
std::fs::create_dir_all(&cli.slurp_cache_path)?; allowed_origins,
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()
} }
let pool = PgPool::connect(&cli.newsreader_database_url).await?; .to_cors()?;
let nm = Notmuch::default();
let rkt = rocket::build()
.mount(
letterbox_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(&cli.newsreader_tantivy_db_path)?; let tantivy_conn = TantivyConnection::new(&config.newsreader_tantivy_db_path)?;
let cacher = FilesystemCacher::new(&cli.slurp_cache_path)?; let cacher = FilesystemCacher::new(&config.slurp_cache_path)?;
let schema = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot) let schema = Schema::build(QueryRoot, Mutation, EmptySubscription)
.data(nm.clone()) .data(Notmuch::default())
.data(cacher) .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 connection_tracker = Arc::new(Mutex::new(ConnectionTracker::default())); let rkt = rkt.manage(schema).manage(pool).manage(Notmuch::default());
let ct = Arc::clone(&connection_tracker); //.manage(Notmuch::with_config("../notmuch/testdata/notmuch.config"))
let poll_time = Duration::from_secs(60);
let _h = tokio::spawn(watch_new(nm.clone(), pool.clone(), ct, poll_time));
let api_routes = Router::new() rkt.launch().await?;
.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(())
} }

View File

@ -1,39 +0,0 @@
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

View File

@ -39,12 +39,4 @@ pub enum ServerError {
QueryParseError(#[from] QueryParserError), QueryParseError(#[from] QueryParserError),
#[error("impossible: {0}")] #[error("impossible: {0}")]
InfaillibleError(#[from] Infallible), InfaillibleError(#[from] Infallible),
#[error("askama error: {0}")]
AskamaError(#[from] askama::Error),
#[error("xml error: {0}")]
XmlError(#[from] quick_xml::Error),
#[error("xml encoding error: {0}")]
XmlEncodingError(#[from] quick_xml::encoding::EncodingError),
#[error("html to text error: {0}")]
Html2TextError(#[from] html2text::Error),
} }

View File

@ -2,22 +2,20 @@ use std::{fmt, str::FromStr};
use async_graphql::{ use async_graphql::{
connection::{self, Connection, Edge, OpaqueCursor}, connection::{self, Connection, Edge, OpaqueCursor},
futures_util::Stream, Context, EmptySubscription, Enum, Error, FieldResult, InputObject, Object, Schema,
Context, Enum, Error, FieldResult, InputObject, Object, Schema, SimpleObject, Subscription, SimpleObject, Union,
Union,
}; };
use cacher::FilesystemCacher; use cacher::FilesystemCacher;
use chrono::{DateTime, Utc};
use futures::stream;
use letterbox_notmuch::Notmuch; use letterbox_notmuch::Notmuch;
use log::info;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use tokio::join; use tokio::join;
use tracing::{info, instrument}; use tracing::instrument;
#[cfg(feature = "tantivy")] #[cfg(feature = "tantivy")]
use crate::tantivy::TantivyConnection; use crate::tantivy::TantivyConnection;
use crate::{newsreader, nm, nm::label_unprocessed, Query}; use crate::{newsreader, nm, Query};
/// # Number of seconds since the Epoch /// # Number of seconds since the Epoch
pub type UnixTime = isize; pub type UnixTime = isize;
@ -97,10 +95,6 @@ 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
@ -238,24 +232,6 @@ impl Body {
content_tree: "".to_string(), content_tree: "".to_string(),
}) })
} }
pub fn to_html(&self) -> Option<String> {
match self {
Body::Html(h) => Some(h.html.clone()),
Body::PlainText(p) => Some(format!("<pre>{}</pre>", html_escape::encode_text(&p.text))),
Body::UnhandledContentType(u) => {
Some(format!("<pre>{}</pre>", html_escape::encode_text(&u.text)))
}
}
}
pub fn to_html_content_tree(&self) -> Option<String> {
match self {
Body::Html(h) => Some(h.content_tree.clone()),
Body::PlainText(p) => Some(p.content_tree.clone()),
Body::UnhandledContentType(u) => Some(u.content_tree.clone()),
}
}
} }
#[derive(Debug, SimpleObject)] #[derive(Debug, SimpleObject)]
@ -309,7 +285,8 @@ impl QueryRoot {
build_info::build_info!(fn bi); build_info::build_info!(fn bi);
Ok(letterbox_shared::build_version(bi)) Ok(letterbox_shared::build_version(bi))
} }
#[instrument(skip_all, fields(query=query, rid=request_id()))] #[instrument(skip_all, fields(query=query))]
#[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>();
@ -329,20 +306,10 @@ 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, rid=request_id()))] #[instrument(skip_all, fields(query=query, request_id=request_id()))]
async fn search<'ctx>( async fn search<'ctx>(
&self, &self,
ctx: &Context<'ctx>, ctx: &Context<'ctx>,
@ -500,7 +467,7 @@ impl QueryRoot {
.await?) .await?)
} }
#[instrument(skip_all, fields(rid=request_id()))] #[instrument(skip_all, fields(request_id=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>();
@ -509,7 +476,7 @@ 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, rid=request_id()))] #[instrument(skip_all, fields(thread_id=thread_id, request_id=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 cacher = ctx.data_unchecked::<FilesystemCacher>();
@ -583,10 +550,10 @@ async fn tantivy_search(
.collect()) .collect())
} }
pub struct MutationRoot; pub struct Mutation;
#[Object] #[Object]
impl MutationRoot { impl Mutation {
#[instrument(skip_all, fields(query=query, unread=unread, rid=request_id()))] #[instrument(skip_all, fields(query=query, unread=unread, request_id=request_id()))]
async fn set_read_status<'ctx>( async fn set_read_status<'ctx>(
&self, &self,
ctx: &Context<'ctx>, ctx: &Context<'ctx>,
@ -605,7 +572,7 @@ impl MutationRoot {
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, rid=request_id()))] #[instrument(skip_all, fields(query=query, tag=tag, request_id=request_id()))]
async fn tag_add<'ctx>( async fn tag_add<'ctx>(
&self, &self,
ctx: &Context<'ctx>, ctx: &Context<'ctx>,
@ -617,7 +584,7 @@ impl MutationRoot {
nm.tag_add(&tag, &query)?; nm.tag_add(&tag, &query)?;
Ok(true) Ok(true)
} }
#[instrument(skip_all, fields(query=query, tag=tag, rid=request_id()))] #[instrument(skip_all, fields(query=query, tag=tag, request_id=request_id()))]
async fn tag_remove<'ctx>( async fn tag_remove<'ctx>(
&self, &self,
ctx: &Context<'ctx>, ctx: &Context<'ctx>,
@ -629,42 +596,6 @@ impl MutationRoot {
nm.tag_remove(&tag, &query)?; nm.tag_remove(&tag, &query)?;
Ok(true) Ok(true)
} }
#[instrument(skip_all, fields(query=query, wake_time=wake_time.to_string(), rid=request_id()))]
async fn snooze<'ctx>(
&self,
ctx: &Context<'ctx>,
query: String,
wake_time: DateTime<Utc>,
) -> Result<bool, Error> {
info!("TODO snooze {query} until {wake_time})");
let pool = ctx.data_unchecked::<PgPool>();
sqlx::query!(
r#"
INSERT INTO snooze (message_id, wake)
VALUES ($1, $2)
ON CONFLICT (message_id) DO UPDATE
SET wake = $2
"#,
query,
wake_time
)
.execute(pool)
.await?;
let nm = ctx.data_unchecked::<Notmuch>();
let pool = ctx.data_unchecked::<PgPool>();
#[cfg(feature = "tantivy")]
let tantivy = ctx.data_unchecked::<TantivyConnection>();
let unread = false;
let query: Query = query.parse()?;
newsreader::set_read_status(pool, &query, unread).await?;
#[cfg(feature = "tantivy")]
tantivy.reindex_thread(pool, &query).await?;
nm::set_read_status(nm, &query, unread).await?;
Ok(true)
}
/// Drop and recreate tantivy index. Warning this is slow /// Drop and recreate tantivy index. Warning this is slow
#[cfg(feature = "tantivy")] #[cfg(feature = "tantivy")]
async fn drop_and_load_index<'ctx>(&self, ctx: &Context<'ctx>) -> Result<bool, Error> { async fn drop_and_load_index<'ctx>(&self, ctx: &Context<'ctx>) -> Result<bool, Error> {
@ -676,32 +607,13 @@ impl MutationRoot {
Ok(true) Ok(true)
} }
#[instrument(skip_all, fields(rid=request_id()))] #[instrument(skip_all, fields(request_id=request_id()))]
async fn label_unprocessed<'ctx>(
&self,
ctx: &Context<'ctx>,
limit: Option<usize>,
) -> Result<bool, Error> {
let nm = ctx.data_unchecked::<Notmuch>();
let pool = ctx.data_unchecked::<PgPool>();
label_unprocessed(&nm, &pool, false, limit, "tag:unprocessed").await?;
Ok(true)
}
#[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 cacher = ctx.data_unchecked::<FilesystemCacher>();
let pool = ctx.data_unchecked::<PgPool>(); 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?; newsreader::refresh(pool, cacher).await?;
// Process email labels
label_unprocessed(&nm, &pool, false, Some(1000), "tag:unprocessed").await?;
// Look for snoozed messages and mark unread
wakeup(&nm, &pool).await?;
#[cfg(feature = "tantivy")] #[cfg(feature = "tantivy")]
{ {
let tantivy = ctx.data_unchecked::<TantivyConnection>(); let tantivy = ctx.data_unchecked::<TantivyConnection>();
@ -712,78 +624,4 @@ impl MutationRoot {
} }
} }
pub struct SubscriptionRoot; pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>;
#[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(name = "wakeup", skip_all)]
pub async fn wakeup(nm: &Notmuch, pool: &PgPool) -> Result<(), Error> {
for row in sqlx::query!(
r#"
SELECT id, message_id
FROM snooze
WHERE wake < NOW();
"#
)
.fetch_all(pool)
.await?
{
let query: Query = row.message_id.parse()?;
info!("need to wake {query}");
let unread = true;
newsreader::set_read_status(pool, &query, unread).await?;
#[cfg(feature = "tantivy")]
tantivy.reindex_thread(pool, &query).await?;
nm::set_read_status(nm, &query, unread).await?;
sqlx::query!("DELETE FROM snooze WHERE id = $1", row.id)
.execute(pool)
.await?;
}
Ok(())
}
#[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)
}

View File

@ -1,11 +1,9 @@
pub mod config; pub mod config;
pub mod email_extract;
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;
@ -19,10 +17,9 @@ use std::{
use async_trait::async_trait; use async_trait::async_trait;
use cacher::{Cacher, FilesystemCacher}; use cacher::{Cacher, FilesystemCacher};
use chrono::NaiveDateTime;
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::{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,
@ -31,11 +28,12 @@ use maplit::{hashmap, hashset};
use regex::Regex; use regex::Regex;
use reqwest::StatusCode; use reqwest::StatusCode;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use sqlx::types::time::PrimitiveDateTime;
use thiserror::Error; use thiserror::Error;
use tracing::{debug, error, info, warn};
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,
@ -320,16 +318,13 @@ impl<'c> Transformer for SlurpContents<'c> {
} else { } else {
let resp = reqwest::get(link.as_str()).await?; let resp = reqwest::get(link.as_str()).await?;
let status = resp.status(); let status = resp.status();
if status.is_server_error() { if status.is_server_error() || retryable_status.contains(&status) {
error!("status error for {link}: {status}"); return Err(TransformError::RetryableHttpStatusError(
return Ok(html.to_string()); status,
} link.to_string(),
if retryable_status.contains(&status) { ));
error!("retryable error for {link}: {status}");
return Ok(html.to_string());
} }
if !status.is_success() { if !status.is_success() {
error!("unsuccessful for {link}: {status}");
return Ok(html.to_string()); return Ok(html.to_string());
} }
let body = resp.text().await?; let body = resp.text().await?;
@ -443,34 +438,6 @@ 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();
@ -715,7 +682,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 = 10000; let default_page_size = 100;
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),
@ -754,7 +721,6 @@ pub struct Query {
pub is_notmuch: bool, pub is_notmuch: bool,
pub is_newsreader: bool, pub is_newsreader: bool,
pub is_tantivy: bool, pub is_tantivy: bool,
pub is_snoozed: bool,
pub corpus: Option<Corpus>, pub corpus: Option<Corpus>,
} }
@ -778,9 +744,6 @@ impl fmt::Display for Query {
if self.is_newsreader { if self.is_newsreader {
write!(f, "is:news ")?; write!(f, "is:news ")?;
} }
if self.is_snoozed {
write!(f, "is:snoozed ")?;
}
match self.corpus { match self.corpus {
Some(c) => write!(f, "corpus:{c:?}")?, Some(c) => write!(f, "corpus:{c:?}")?,
_ => (), _ => (),
@ -810,19 +773,7 @@ impl Query {
for uid in &self.uids { for uid in &self.uids {
parts.push(uid.clone()); parts.push(uid.clone());
} }
for r in &self.remainder { parts.extend(self.remainder.clone());
// 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(" ")
} }
} }
@ -837,7 +788,6 @@ impl FromStr for Query {
let mut is_notmuch = false; let mut is_notmuch = false;
let mut is_newsreader = false; let mut is_newsreader = false;
let mut is_tantivy = false; let mut is_tantivy = false;
let mut is_snoozed = false;
let mut corpus = None; let mut corpus = None;
for word in s.split_whitespace() { for word in s.split_whitespace() {
if word == "is:unread" { if word == "is:unread" {
@ -877,8 +827,6 @@ impl FromStr for Query {
is_newsreader = true; is_newsreader = true;
} else if word == "is:newsreader" { } else if word == "is:newsreader" {
is_newsreader = true; is_newsreader = true;
} else if word == "is:snoozed" {
is_snoozed = true;
} else { } else {
remainder.push(word.to_string()); remainder.push(word.to_string());
} }
@ -897,14 +845,13 @@ impl FromStr for Query {
is_notmuch, is_notmuch,
is_newsreader, is_newsreader,
is_tantivy, is_tantivy,
is_snoozed,
corpus, corpus,
}) })
} }
} }
pub struct ThreadSummaryRecord { pub struct ThreadSummaryRecord {
pub site: Option<String>, pub site: Option<String>,
pub date: Option<NaiveDateTime>, pub date: Option<PrimitiveDateTime>,
pub is_read: Option<bool>, pub is_read: Option<bool>,
pub title: Option<String>, pub title: Option<String>,
pub uid: String, pub uid: String,
@ -922,7 +869,11 @@ async fn thread_summary_from_row(r: ThreadSummaryRecord) -> ThreadSummary {
title = clean_title(&title).await.expect("failed to clean title"); title = clean_title(&title).await.expect("failed to clean title");
ThreadSummary { ThreadSummary {
thread: format!("{NEWSREADER_THREAD_PREFIX}{}", r.uid), thread: format!("{NEWSREADER_THREAD_PREFIX}{}", r.uid),
timestamp: r.date.expect("post missing date").and_utc().timestamp() as isize, timestamp: r
.date
.expect("post missing date")
.assume_utc()
.unix_timestamp() as isize,
date_relative: format!("{:?}", r.date), date_relative: format!("{:?}", r.date),
//date_relative: "TODO date_relative".to_string(), //date_relative: "TODO date_relative".to_string(),
matched: 0, matched: 0,
@ -947,21 +898,3 @@ 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(())
}
}

113
server/src/mail.rs Normal file
View File

@ -0,0 +1,113 @@
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()),
};
}

View File

@ -3,10 +3,11 @@ use std::collections::HashMap;
use cacher::FilesystemCacher; use cacher::FilesystemCacher;
use futures::{stream::FuturesUnordered, StreamExt}; use futures::{stream::FuturesUnordered, StreamExt};
use letterbox_shared::compute_color; use letterbox_shared::compute_color;
use log::{error, info};
use maplit::hashmap; use maplit::hashmap;
use scraper::Selector; use scraper::Selector;
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use tracing::{error, info, instrument, warn}; use tracing::instrument;
use url::Url; use url::Url;
use crate::{ use crate::{
@ -86,10 +87,6 @@ pub async fn search(
query: &Query, query: &Query,
) -> Result<Vec<(i32, ThreadSummary)>, async_graphql::Error> { ) -> Result<Vec<(i32, ThreadSummary)>, async_graphql::Error> {
info!("search({after:?} {before:?} {first:?} {last:?} {query:?}"); info!("search({after:?} {before:?} {first:?} {last:?} {query:?}");
if query.is_snoozed {
warn!("TODO implement snooze for newsreader::search");
return Ok(Vec::new());
}
if !is_newsreader_query(query) { if !is_newsreader_query(query) {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@ -215,7 +212,11 @@ pub async fn thread(
} }
let title = clean_title(&r.title.unwrap_or("NO TITLE".to_string())).await?; let title = clean_title(&r.title.unwrap_or("NO TITLE".to_string())).await?;
let is_read = r.is_read.unwrap_or(false); let is_read = r.is_read.unwrap_or(false);
let timestamp = r.date.expect("post missing date").and_utc().timestamp(); let timestamp = r
.date
.expect("post missing date")
.assume_utc()
.unix_timestamp();
Ok(Thread::News(NewsPost { Ok(Thread::News(NewsPost {
thread_id, thread_id,
is_read, is_read,
@ -352,9 +353,6 @@ fn slurp_contents_selectors() -> HashMap<String, Vec<Selector>> {
"natwelch.com".to_string() => vec![ "natwelch.com".to_string() => vec![
Selector::parse("article div.prose").unwrap(), Selector::parse("article div.prose").unwrap(),
], ],
"seiya.me".to_string() => vec![
Selector::parse("header + div").unwrap(),
],
"rustacean-station.org".to_string() => vec![ "rustacean-station.org".to_string() => vec![
Selector::parse("article").unwrap(), Selector::parse("article").unwrap(),
], ],

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +0,0 @@
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);
}
}
}

View File

@ -1,99 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>DMARC Report</title>
</head>
<body>
{% if report.report_metadata.is_some() %}
{% let meta = report.report_metadata.as_ref().unwrap() %}
<b>Reporter:</b> {{ meta.org_name }}<br>
<b>Contact:</b> {{ meta.email }}<br>
<b>Report ID:</b> {{ meta.report_id }}<br>
{% if meta.date_range.is_some() %}
{% let dr = meta.date_range.as_ref().unwrap() %}
<b>Date range:</b>
{{ dr.begin }}
to
{{ dr.end }}
<br>
{% endif %}
{% endif %}
{% if report.policy_published.is_some() %}
{% let pol = report.policy_published.as_ref().unwrap() %}
<b>Policy Published:</b>
<ul>
<li>Domain: {{ pol.domain }}</li>
<li>ADKIM: {{ pol.adkim }}</li>
<li>ASPF: {{ pol.aspf }}</li>
<li>Policy: {{ pol.p }}</li>
<li>Subdomain Policy: {{ pol.sp }}</li>
<li>Percent: {{ pol.pct }}</li>
</ul>
{% endif %}
{% if report.record.is_some() %}
<b>Records:</b>
<table style="border-collapse:collapse;width:100%;font-size:0.95em;">
<thead>
<tr style="background:#f0f0f0;">
<th style="border:1px solid #bbb;padding:4px 8px;">Source IP</th>
<th style="border:1px solid #bbb;padding:4px 8px;">Count</th>
<th style="border:1px solid #bbb;padding:4px 8px;">Header From</th>
{% if report.has_envelope_to %}
<th style="border:1px solid #bbb;padding:4px 8px;">Envelope To</th>
{% endif %}
<th style="border:1px solid #bbb;padding:4px 8px;">Disposition</th>
<th style="border:1px solid #bbb;padding:4px 8px;">DKIM</th>
<th style="border:1px solid #bbb;padding:4px 8px;">SPF</th>
<th style="border:1px solid #bbb;padding:4px 8px;">Auth Results</th>
</tr>
</thead>
<tbody>
{% for rec in report.record.as_ref().unwrap() %}
<tr>
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.source_ip }}</td>
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.count }}</td>
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.header_from }}</td>
{% if report.has_envelope_to %}
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.envelope_to }}</td>
{% endif %}
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.disposition }}</td>
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.dkim }}</td>
<td style="border:1px solid #bbb;padding:4px 8px;">{{ rec.spf }}</td>
<td style="border:1px solid #bbb;padding:4px 8px;">
{% if rec.auth_results.is_some() %}
{% let auth = rec.auth_results.as_ref().unwrap() %}
{% for dkimres in auth.dkim %}
<span style="white-space:nowrap;">
DKIM: domain=<b>{{ dkimres.domain }}</b>
selector=<b>{{ dkimres.selector }}</b>
result=<b>{{ dkimres.result }}</b>
</span><br>
{% endfor %}
{% for spfres in auth.spf %}
<span style="white-space:nowrap;">
SPF: domain=<b>{{ spfres.domain }}</b>
scope=<b>{{ spfres.scope }}</b>
result=<b>{{ spfres.result }}</b>
</span><br>
{% endfor %}
{% for reason in rec.reason %}
<span style="white-space:nowrap;">Reason: {{ reason }}</span><br>
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if report.report_metadata.is_none() && report.policy_published.is_none() && report.record.is_none() %}
<p>No DMARC summary found.</p>
{% endif %}
</body>
</html>

View File

@ -1,109 +0,0 @@
<style>
.ical-flex {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
gap: 0.5em;
max-width: 700px;
width: 100%;
}
.ical-flex .summary-block {
flex: 1 1 0%;
}
.ical-flex .calendar-block {
flex: none;
margin-left: auto;
}
@media (max-width: 599px) {
.ical-flex {
flex-direction: column;
}
.ical-flex>div.summary-block {
margin-bottom: 0.5em;
margin-left: 0;
}
.ical-flex>div.calendar-block {
margin-left: 0;
}
}
</style>
<div class="ical-flex">
<div class="summary-block"
style="background:#f7f7f7; border-radius:8px; box-shadow:0 2px 8px #bbb; padding:16px 18px; margin:0 0 8px 0; min-width:220px; max-width:700px; font-size:15px; color:#222;">
<div
style="display: flex; flex-direction: row; flex-wrap: wrap; align-items: flex-start; gap: 0.5em; width: 100%;">
<div style="flex: 1 1 220px; min-width: 180px;">
<div style="font-size:17px; font-weight:bold; margin-bottom:8px; color:#333;"><b>Summary:</b> {{ summary
}}</div>
<div style="margin-bottom:4px;"><b>Start:</b> {{ local_fmt_start }}</div>
<div style="margin-bottom:4px;"><b>End:</b> {{ local_fmt_end }}</div>
{% if !recurrence_display.is_empty() %}
<div style="margin-bottom:4px;">
<b>Repeats:</b> {{ recurrence_display }}
</div>
{% endif %}
{% if !organizer_cn.is_empty() %}
<div style="margin-bottom:4px;"><b>Organizer:</b> {{ organizer_cn }}</div>
{% elif !organizer.is_empty() %}
<div style="margin-bottom:4px;"><b>Organizer:</b> {{ organizer }}</div>
{% endif %}
</div>
{% if all_days.len() > 0 %}
<div class="calendar-block" style="flex: none; margin-left: auto; min-width: 180px;">
<table class="ical-month"
style="border-collapse:collapse; min-width:220px; background:#fff; box-shadow:0 2px 8px #bbb; font-size:14px; margin:0;">
<caption
style="caption-side:top; text-align:center; font-weight:bold; font-size:16px; padding-bottom:8px 0;">
{{ caption }}</caption>
<thead>
<tr>
{% for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] %}
<th
style="padding:4px 6px; border-bottom:1px solid #ccc; color:#666; font-weight:600; background:#f7f7f7">
{{ wd }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for week in all_days|batch(7) %}
<tr>
{% for day in week %}
{% if event_days.contains(day) %}
<td
data-event-day="{{ day.format("%Y-%m-%d") }}"
style="background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;">
{{ day.day() }}
</td>
{% elif today.is_some() && today.unwrap() == day %}
<td
style="border:2px solid #2196f3; border-radius:4px; text-align:center; background:#e3f2fd; color:#222; box-shadow:0 0 0 2px #2196f3;">
{{ day.day() }}
</td>
{% else %}
<td style="border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;">
{{ day.day() }}
</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
{% if !description_paragraphs.is_empty() %}
<div style="max-width:700px; width:100%;">
{% for p in description_paragraphs %}
<p style="margin: 0 0 8px 0; color:#444;">{{ p }}</p>
{% endfor %}
</div>
{% endif %}

View File

@ -1,48 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>TLS Report</title>
</head>
<body>
<h3>TLS Report Summary:</h3>
<p>Organization: {{ report.organization_name }}</p>
<p>Date Range: {{ report.date_range.start_datetime }} to {{ report.date_range.end_datetime }}</p>
<p>Contact: {{ report.contact_info }}</p>
<p>Report ID: {{ report.report_id }}</p>
<h4><b>Policies:</b></h4>
{% for policy in report.policies %}
<h5><b>Policy Domain:</b> {{ policy.policy.policy_domain }}</h5>
<ul>
<li><b>Policy Type:</b> {{ policy.policy.policy_type }}</li>
<li><b>Policy String:</b> {{ policy.policy.policy_string | join(", ") }}</li>
<li><b>Successful Sessions:</b> {{ policy.summary.total_successful_session_count }}</li>
<li><b>Failed Sessions:</b> {{ policy.summary.total_failure_session_count }}</li>
</ul>
<ul>
{% for mx_host in policy.policy.mx_host %}
<li><b>Hostname:</b> {{ mx_host.hostname }}, <b>Failures:</b> {{ mx_host.failure_count }}, <b>Result:</b> {{
mx_host.result_type }}</li>
{% endfor %}
</ul>
<ul>
{% for detail in policy.failure_details %}
<li><b>Result:</b> {{ detail.result_type }}, <b>Sending IP:</b> {{ detail.sending_mta_ip }}, <b>Failed
Sessions:</b> {{ detail.failed_session_count }}
{% if detail.failure_reason_code != "" %}
(<b>Reason:</b> {{ detail.failure_reason_code }})
{% endif %}
</li>
(<b>Receiving IP:</b> {{ detail.receiving_ip }})
(<b>Receiving MX:</b> {{ detail.receiving_mx_hostname }})
(<b>Additional Info:</b> {{ detail.additional_info }})
{% endfor %}
</ul>
{% endfor %}
</body>
</html>

View File

@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<version>1.0</version>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<extra_contact_info>https://support.google.com/a/answer/2466580</extra_contact_info>
<report_id>5142106658860834914</report_id>
<date_range>
<begin>1755302400</begin>
<end>1755388799</end>
</date_range>
</report_metadata>
<policy_published>
<domain>xinu.tv</domain>
<adkim>s</adkim>
<aspf>s</aspf>
<p>quarantine</p>
<sp>reject</sp>
<pct>100</pct>
<np>reject</np>
</policy_published>
<record>
<row>
<source_ip>74.207.253.222</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>xinu.tv</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>xinu.tv</domain>
<result>pass</result>
<selector>mail</selector>
</dkim>
<spf>
<domain>xinu.tv</domain>
<result>pass</result>
</spf>
</auth_results>
</record>
</feedback>

View File

@ -1,78 +0,0 @@
<?xml version="1.0"?>
<feedback xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<version>1.0</version>
<report_metadata>
<org_name>Outlook.com</org_name>
<email>dmarcreport@microsoft.com</email>
<report_id>e6c5a2ce6e074d7d8cd041a0d6f32a3d</report_id>
<date_range>
<begin>1755302400</begin>
<end>1755388800</end>
</date_range>
</report_metadata>
<policy_published>
<domain>xinu.tv</domain>
<adkim>s</adkim>
<aspf>s</aspf>
<p>quarantine</p>
<sp>reject</sp>
<pct>100</pct>
<fo>1</fo>
</policy_published>
<record>
<row>
<source_ip>74.207.253.222</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<envelope_to>msn.com</envelope_to>
<envelope_from>xinu.tv</envelope_from>
<header_from>xinu.tv</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>xinu.tv</domain>
<selector>mail</selector>
<result>pass</result>
</dkim>
<spf>
<domain>xinu.tv</domain>
<scope>mfrom</scope>
<result>pass</result>
</spf>
</auth_results>
</record>
<record>
<row>
<source_ip>74.207.253.222</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<envelope_to>hotmail.com</envelope_to>
<envelope_from>xinu.tv</envelope_from>
<header_from>xinu.tv</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>xinu.tv</domain>
<selector>mail</selector>
<result>pass</result>
</dkim>
<spf>
<domain>xinu.tv</domain>
<scope>mfrom</scope>
<result>pass</result>
</spf>
</auth_results>
</record>
</feedback>

View File

@ -1,167 +0,0 @@
Return-Path: <couchmoney+caf_=gmail=xinu.tv@gmail.com>
Delivered-To: bill@xinu.tv
Received: from phx.xinu.tv [74.207.253.222]
by nixos-01.h.xinu.tv with IMAP (fetchmail-6.5.1)
for <wathiede@localhost> (single-drop); Mon, 25 Aug 2025 14:29:47 -0700 (PDT)
Received: from phx.xinu.tv
by phx.xinu.tv with LMTP
id TPD3E8vVrGjawyMAJR8clQ
(envelope-from <couchmoney+caf_=gmail=xinu.tv@gmail.com>)
for <bill@xinu.tv>; Mon, 25 Aug 2025 14:29:47 -0700
X-Original-To: gmail@xinu.tv
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=2a00:1450:4864:20::12e; helo=mail-lf1-x12e.google.com; envelope-from=couchmoney+caf_=gmail=xinu.tv@gmail.com; receiver=xinu.tv
Authentication-Results: phx.xinu.tv;
dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20230601 header.b=4sz9KOqm
Received: from mail-lf1-x12e.google.com (mail-lf1-x12e.google.com [IPv6:2a00:1450:4864:20::12e])
by phx.xinu.tv (Postfix) with ESMTPS id 2F9058B007
for <gmail@xinu.tv>; Mon, 25 Aug 2025 14:29:45 -0700 (PDT)
Received: by mail-lf1-x12e.google.com with SMTP id 2adb3069b0e04-55f4969c95aso994593e87.0
for <gmail@xinu.tv>; Mon, 25 Aug 2025 14:29:45 -0700 (PDT)
ARC-Seal: i=2; a=rsa-sha256; t=1756157384; cv=pass;
d=google.com; s=arc-20240605;
b=Y2CP7y9twLnWB5v8iyzZCw0vp33wQBS0qzltdtzX2NIWFhHu6MEp2XH8cONssaGrEN
kyjXajT7uaEpn6G8H6/NB9v9Vo2yk5Lq2f+RhODMYoocYs9YY9NJI4ZxMph0UeMO6RkQ
m+HH0iIeC2Mzgj1Bzq4qFEwb397YIijoxx+1RxyA2D3cwSuZtERSvFOEkHqv9ziWxBcD
u3tvySEuzjyQFU6bxfkax6sZljSRGzfj0iZJAl/Fw5tUgrhndQ55O5RDe4NfPNj0cw/3
XDELzsnepBgnW8Jpqpnh7iK6XMFSf4sPQmyiMCMDNVYtmm6hYFNo3/dOpgaPn/ImRr8j
d9lw==
ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:from:subject:date:message-id:auto-submitted:reply-to
:mime-version:dkim-signature:delivered-to;
bh=RJDaNO07yMMdVMfY1VnSbfmQtoKb6bs6XzWwF6+91ZY=;
fh=xB02AmI2fnPF5rMnM90IwqQ6Il76V+xMgSnSW+E42fE=;
b=H7Ze4a8zoCYB77xcnUnFTogJ/utYS/USzTL/7eS3nA6OPbD+zWRiiVmbSfQcNK7d25
LapXyYnRJKgc8sqqQ6XO26STA8xx/9G620pdTytChIzKsmm/T5cdlf1M8DJ+NlwkzzSG
6Xe5I0MuXSKzBDMmcBcMlY9+mp61eZNo/cGT34MfZvLDS7JCs5uQYy2gRyajCKzRddEP
NBfMgnP1Ag9B5KkpJr4QfA2IWoNlj/qom/bRcdcdjwQ3gwDeiG8rdrEwBt9juwqk8d95
C0LnVKfrXAZgolmJpljyIFb1IMMyBUIQhK+7cXFhV1AD6Laz0df9gmPWp5mGZz9qlYaY
BqJA==;
darn=xinu.tv
ARC-Authentication-Results: i=2; mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=4sz9KOqm;
spf=pass (google.com: domain of 3odssaaoscuanoeqnnkpiuugcvvnguejqqnu.qtieqwejoqpgaiockn.eqo@calendar-server.bounces.google.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=3OdSsaAoSCuANOEQNNKPIUUGCVVNGUEJQQNU.QTIEQWEJOQPGaIOCKN.EQO@calendar-server.bounces.google.com;
dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=google.com;
dara=pass header.i=@gmail.com
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1756157384; x=1756762184;
h=to:from:subject:date:message-id:auto-submitted:reply-to
:mime-version:dkim-signature:delivered-to:x-forwarded-for
:x-forwarded-to:x-gm-message-state:from:to:cc:subject:date
:message-id:reply-to;
bh=RJDaNO07yMMdVMfY1VnSbfmQtoKb6bs6XzWwF6+91ZY=;
b=m95okwnmqNvW4GhCfY8yZvCu5NxuhHCL2+A54SlIrRudednXK05YGzjZ5LOuCAaY1g
htpRv2cGHBj2mEnHh+3GIX5vQCmXw2ptzOGzfYe9TwavuKPkkKPiSD5wA1fk8quqHDOD
4XDM7dsn3xewJ+6GQyc6NPBQq53hmpAojbLXnmNtAIyfAvuxtHP1G+GSO+ZIApgg56K6
TaYrwqnRx66P8B2Ze111LCdnmOOLzweJ1muYyavPdCtTG5BbJgqzaI67bQhuUNZDhVbP
FdtT4Q7WzNt30JHCVIAkkHejD9Fh/mYSmETXpD+ISvZJ47DNnLP4RXjmmAWcHJkKsh+q
v3QQ==
X-Forwarded-Encrypted: i=2; AJvYcCUeIjyIxPoWuMqg9l5aomQv7Z9wLYkwDIS1FYz7bNmHs1Cs0CSHG8Y5B0iU/nlo9xRenTW/Xw==@xinu.tv
X-Gm-Message-State: AOJu0Yznjr5TC7UpZJk74jrsJzMBwx6/39s9e5ufIA5/FmHZ6I1bEdTc
vqpeeLdzSZTI2uZiR7zzKHiwmNJHt/LncR9kDR5f0I6b3MZuXpAgr0aKYdXw7B+b+h7D7uMM3Tm
JF9ccf09JxIzRzeRI9Vb52PUs4SIeiIU9J80QY53UqN/Rx8XMF+ncRSX5d4V4pQ==
X-Received: by 2002:a05:6512:110e:b0:55f:3bab:f204 with SMTP id 2adb3069b0e04-55f3babf35emr3087055e87.31.1756156987711;
Mon, 25 Aug 2025 14:23:07 -0700 (PDT)
X-Forwarded-To: gmail@xinu.tv
X-Forwarded-For: couchmoney@gmail.com gmail@xinu.tv
Delivered-To: couchmoney@gmail.com
Received: by 2002:a05:6504:6116:b0:2b8:eb6f:82ec with SMTP id i22csp44357ltt;
Mon, 25 Aug 2025 14:23:06 -0700 (PDT)
X-Received: by 2002:a05:6e02:164e:b0:3ed:94a6:2edb with SMTP id e9e14a558f8ab-3ed94a63097mr41416195ab.21.1756156986122;
Mon, 25 Aug 2025 14:23:06 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1756156986; cv=none;
d=google.com; s=arc-20240605;
b=Nu0W/67J2nYqDAXf27QdfmUyuA6TGJwusKLaHRaE05YdEu/FWLfUk2ATV+g3iUQ19b
wh7awaA5kemxwiBqAy5kjjlXqlDrkK0Ow2fANdc6lRKvlRNJRYUnojMkP8w/v4Nv8YQj
Wci0HMhL4ni/yeqXeoaj1yKtwJU5MvRMxZZC7TinlCHKF5+MqgD8VNax8OTDOqxYvSDi
aIlyUBTial0AiP/K+3bsoIWEc2RoyBBBNIe88C4s1fcv17GCGn5RkN3lYtr+nwvp5wNE
fKxPCYMtXkNyv8jgjmgxKLcYBDK0B4Zo+ghMWXZneDWo3qotDVkr0GBC3J2N7BcZpjCA
XEDA==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:from:subject:date:message-id:auto-submitted:reply-to
:mime-version:dkim-signature;
bh=RJDaNO07yMMdVMfY1VnSbfmQtoKb6bs6XzWwF6+91ZY=;
fh=mbzrMIWIgWMC0ni1xEx+ViW4J0RLAdLdPT2cX81nTlk=;
b=NvhrlkKGEVx63UMsx510U8ePUo7OgRQBWxZ4BIpQWg6Fk0jJPaZgRoEpUdZ747et1P
rWTx/yVaEUHBqWtt0I4ktiD8Hr4cVqAwKvtiN32JpkGCsVBjYBWqxEalWIOg6abn8xLE
7x9j4GqD/cQhd3DiS6UtADsJ67MjjzLpGkskvxo67vKRGCfSLCKdbna2LO5TtoZ7fKO7
i+dhDol6IIgA2Sg+PZlzq6gbZTaFbglUNI7uOwz0fNWjhHH4ZfmPEycYxJ9bTuPISrqS
BkXxGQFkvlg42NHWt5L8aPzrx8OMoYfTniIqU19GeEFEVUbmzYCg/twZ0f5nxugHWDbD
PMvQ==;
dara=google.com
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=4sz9KOqm;
spf=pass (google.com: domain of 3odssaaoscuanoeqnnkpiuugcvvnguejqqnu.qtieqwejoqpgaiockn.eqo@calendar-server.bounces.google.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=3OdSsaAoSCuANOEQNNKPIUUGCVVNGUEJQQNU.QTIEQWEJOQPGaIOCKN.EQO@calendar-server.bounces.google.com;
dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=google.com;
dara=pass header.i=@gmail.com
Received: from mail-sor-f73.google.com (mail-sor-f73.google.com. [209.85.220.73])
by mx.google.com with SMTPS id ca18e2360f4ac-886c8fc41ebsor461233039f.7.2025.08.25.14.23.05
for <couchmoney@gmail.com>
(Google Transport Security);
Mon, 25 Aug 2025 14:23:06 -0700 (PDT)
Received-SPF: pass (google.com: domain of 3odssaaoscuanoeqnnkpiuugcvvnguejqqnu.qtieqwejoqpgaiockn.eqo@calendar-server.bounces.google.com designates 209.85.220.73 as permitted sender) client-ip=209.85.220.73;
Authentication-Results: mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=4sz9KOqm;
spf=pass (google.com: domain of 3odssaaoscuanoeqnnkpiuugcvvnguejqqnu.qtieqwejoqpgaiockn.eqo@calendar-server.bounces.google.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=3OdSsaAoSCuANOEQNNKPIUUGCVVNGUEJQQNU.QTIEQWEJOQPGaIOCKN.EQO@calendar-server.bounces.google.com;
dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=google.com;
dara=pass header.i=@gmail.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=google.com; s=20230601; t=1756156985; x=1756761785; dara=google.com;
h=to:from:subject:date:message-id:auto-submitted:reply-to
:mime-version:from:to:cc:subject:date:message-id:reply-to;
bh=RJDaNO07yMMdVMfY1VnSbfmQtoKb6bs6XzWwF6+91ZY=;
b=4sz9KOqmGGwObcaR0iSSMVeeMvZHqMzvY4cw++RddJd0V48WoyPPI5q1oMeGiVZ6fm
eEWVr8xH9/T1JUqUZXJHY6CPixN9nTpLvZlpikG1KOFv5+I5DNVX/O5i6M5C/yIPRVGv
ja0ygA7WTL48IkHV7+PTPwHmhF8zv1/BeNdko4BSywfql64J6NMM5RnOAejTIf5AR/IL
CW7H2IcmiOGBHfgMApQljg3wB+WgUel7RXZfMnHCbSlmynJ6bDJ4tq7uU16GLpnI6qAe
s9w8cOpFPiQk8uKEqdc682XxKlwqYdh07RWO/EdlZ8WeSoxMfU6YZL7c1s6xxK2c9sT7
8Xxg==
X-Google-Smtp-Source: AGHT+IFJwttd47Uo06h0EKkogFtVf4poWcHfmodh4dZqSviwYROSgnnyI2ZJSibXGnOUHiLIfAwFn6KP9CzXMoyncWSb
MIME-Version: 1.0
X-Received: by 2002:a05:6602:14c9:b0:884:47f0:b89f with SMTP id
ca18e2360f4ac-886bd0f2960mr1726062839f.3.1756156985586; Mon, 25 Aug 2025
14:23:05 -0700 (PDT)
Reply-To: tconvertino@gmail.com
Auto-Submitted: auto-generated
Message-ID: <calendar-43033c42-cc1e-4014-a5e8-c4552d41247e@google.com>
Date: Mon, 25 Aug 2025 21:23:05 +0000
Subject: New event: McClure BLT @ Monthly from 7:30am to 8:30am on the second
Thursday from Thu Sep 11 to Fri Jan 30, 2026 (PDT) (tconvertino@gmail.com)
From: "lmcollings@seattleschools.org (Google Calendar)" <calendar-notification@google.com>
To: couchmoney@gmail.com
Content-Type: multipart/alternative; boundary="0000000000004bc1be063d372904"
--0000000000004bc1be063d372904
Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes
Content-Transfer-Encoding: base64
TWNDbHVyZSBCTFQNCk1vbnRobHkgZnJvbSA3OjMwYW0gdG8gODozMGFtIG9uIHRoZSBzZWNvbmQg
VGh1cnNkYXkgZnJvbSBUaHVyc2RheSBTZXAgMTEgIA0KdG8gRnJpZGF5IEphbiAzMCwgMjAyNg0K
UGFjaWZpYyBUaW1lIC0gTG9zIEFuZ2VsZXMNCg0KTG9jYXRpb24NCk1jQ2x1cmUgTGlicmFyeQkN
Cmh0dHBzOi8vd3d3Lmdvb2dsZS5jb20vbWFwcy9zZWFyY2gvTWNDbHVyZStMaWJyYXJ5P2hsPWVu
DQoNCg0KDQpCTFQgd2lsbCBtZWV0IG9uIHRoZSAybmQgVGh1cnNkYXkgb2YgZXZlcnkgbW9udGgg
dW50aWwgSmFudWFyeSB3aGVuIHdlICANCmJlZ2luIGxvb2tpbmcgYXQgYnVkZ2V0LiBBZGRpdGlv
bmFsIG1lZXRpbmdzIG1heSBhbHNvIGJlIHNjaGVkdWxlZCBlYXJsaWVyICANCmlmIG5lZWRlZC4N
ClRoYW5rcywNCk1jQ2x1cmUgQkxUDQoNCg0KDQpPcmdhbml6ZXINCmxtY29sbGluZ3NAc2VhdHRs
ZXNjaG9vbHMub3JnDQpsbWNvbGxpbmdzQHNlYXR0bGVzY2hvb2xzLm9yZw0KDQpHdWVzdHMNCmxt
Y29sbGluZ3NAc2VhdHRsZXNjaG9vbHMub3JnIC0gb3JnYW5pemVyDQp0Y29udmVydGlub0BnbWFp
bC5jb20gLSBjcmVhdG9yDQptYW5kcy5hbmRydXNAZ21haWwuY29tDQphbXNjaHVtZXJAc2VhdHRs
ZXNjaG9vbHMub3JnDQphcGplbm5pbmdzQHNlYXR0bGVzY2hvbHMub3JnDQpsbWJsYXVAc2VhdHRs
ZXNjaG9vbHMub3JnDQptbmxhbmRpc0BzZWF0dGxlc2Nob29scy5vcmcNCnRtYnVyY2hhcmR0QHNl
YXR0bGVzY2hvb2xzLm9yZw0KbWNjbHVyZWFsbHN0YWZmQHNlYXR0bGVzY2hvbHMub3JnIC0gb3B0
aW9uYWwNClZpZXcgYWxsIGd1ZXN0IGluZm8gIA0KaHR0cHM6Ly9jYWxlbmRhci5nb29nbGUuY29t
L2NhbGVuZGFyL3I/ZWlkPVh6WXdjVE13WXpGbk5qQnZNekJsTVdrMk1HODBZV016WnpZd2NtbzRa
M0JzT0RoeWFqSmpNV2c0TkhNelpHZzVae1l3Y3pNd1l6Rm5OakJ2TXpCak1XYzNORG96T0dkb2Fq
WXhNR3RoWjNFeE5qUnhhemhuY0djMk5HOHpNR014WnpZd2J6TXdZekZuTmpCdk16QmpNV2MyTUc4
ek1tTXhaell3YnpNd1l6Rm5PR2R4TTJGalNXODNOSUF6YVdReGJUY3hNbXBqWkRGck5qVXhNamhq
TVcwM01USnFNbWRvYnpnMGN6TTJaSEJwTmprek1DQjBZMjl1ZG1WeWRHbHViMEJ0JmVzPTENCg0K
fn4vL35+DQpJbnZpdGF0aW9uIGZyb20gR29vZ2xlIENhbGVuZGFyOiBodHRwczovL2NhbGVuZGFy
Lmdvb2dsZS5jb20vY2FsZW5kYXIvDQoNCllvdSBhcmUgcmVjZWl2aW5nIHRoaXMgZW1haWwgYmVj
YXVzZSB5b3UgYXJlIHN1YnNjcmliZWQgdG8gY2FsZW5kYXIgIA0Kbm90aWZpY2F0aW9ucy4gVG8g
c3RvcCByZWNlaXZpbmcgdGhlc2UgZW1haWxzLCBnbyB0byAgDQpodHRwczovL2NhbGVuZGFyLmdv
b2dsZS5jb20vY2FsZW5kYXIvci9zZXR0aW5ncywgc2VsZWN0IHRoaXMgY2FsZW5kYXIsIGFuZCANCmNoYW5nZSAiT3RoZXIgbm90aWZpY2F0aW9ucyIuDQoNCkZvcndhcmRpbmcgdGhpcyBpbnZp
dGF0aW9uIGNvdWxkIGFsbG93IGFueSByZWNpcGllbnQgdG8gc2VuZCBhIHJlc3BvbnNlIHRvICAN
CnRoZSBvcmdhbml6ZXIsIGJlIGFkZGVkIHRvIHRoZSBndWVzdCBsaXN0LCBpbnZpdGUgb3RoZXJz
IHJlZ2FyZGxlc3Mgb2YgIA0KdGhlaXIgb3duIGludml0YXRpb24gc3RhdHVzLCBvciBtb2RpZnkg
eW91ciBSU1ZQLg0KDQpMZWFybiBtb3JlIGh0dHBzOi8vc3VwcG9ydC5nb29nbGUuY29tL2NhbGVu
ZGFyL2Fuc3dlci8zNzEzNSNmb3J3YXJkaW5nDQo=
--0000000000004bc1be063d372904--

View File

@ -1,206 +0,0 @@
Return-Path: <couchmoney+caf_=gmail=xinu.tv@gmail.com>
Delivered-To: bill@xinu.tv
Received: from phx.xinu.tv [74.207.253.222]
by nixos-01.h.xinu.tv with IMAP (fetchmail-6.5.1)
for <wathiede@localhost> (single-drop); Thu, 28 Aug 2025 12:11:15 -0700 (PDT)
Received: from phx.xinu.tv
by phx.xinu.tv with LMTP
id 1gVrANOpsGg9TSQAJR8clQ
(envelope-from <couchmoney+caf_=gmail=xinu.tv@gmail.com>)
for <bill@xinu.tv>; Thu, 28 Aug 2025 12:11:15 -0700
X-Original-To: gmail@xinu.tv
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=2a00:1450:4864:20::230; helo=mail-lj1-x230.google.com; envelope-from=couchmoney+caf_=gmail=xinu.tv@gmail.com; receiver=xinu.tv
Authentication-Results: phx.xinu.tv;
dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20230601 header.b=RjBRlfFL;
dkim=pass (2048-bit key; unprotected) header.d=gmail.com header.i=@gmail.com header.a=rsa-sha256 header.s=20230601 header.b=HaiL0lRL
Received: from mail-lj1-x230.google.com (mail-lj1-x230.google.com [IPv6:2a00:1450:4864:20::230])
by phx.xinu.tv (Postfix) with ESMTPS id B4E848B007
for <gmail@xinu.tv>; Thu, 28 Aug 2025 12:11:13 -0700 (PDT)
Received: by mail-lj1-x230.google.com with SMTP id 38308e7fff4ca-336a85b8fc5so8142611fa.3
for <gmail@xinu.tv>; Thu, 28 Aug 2025 12:11:13 -0700 (PDT)
ARC-Seal: i=2; a=rsa-sha256; t=1756408272; cv=pass;
d=google.com; s=arc-20240605;
b=Nq93fJSEgPuxWsaf3dc6cCKbOP/bXMQJfmuZJBvrid99GipahJY/Ka4SGoLc8HBMH2
Ip9YDLG2Lblqz/N1KOud9gnAmQ6Zg4hfPZGvhUfCGaXbCi2lOhRlfx6QM0lM1B8rAXaA
S3Lt2qFFXrVBlvaJePwI+wVpc1wPbvd5PblaaUTYUVJeYSfdPtgNAy0Aehty9TF0Jo2h
9yrzCWMJ6kMTpsDw7sfDSnv7s43Q3jOPzXDjHdJfrK8aUXGQenwT+1acJkIw78wBFt3R
IG5CBLIKmwDpjquJzRPkEjHiNDRxhaKaCShTCVLTjmrYgbHXPM/gUewaKLfeIuTzOVuA
mnkw==
ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature:delivered-to;
bh=lgr/fFBrye/qM438Us9TAp1/DYWNuYxn2NUL4vzX/SU=;
fh=twOWSYT+4sbeBuT1oeA5xzauBIj0SLZH5qI1YanOQio=;
b=FBstDUezbqJRRRxTwlKY4UXNSJ4z9aZdvb9KOlxXfFLCzUh3r5w+9P4+a/uH1Uw65g
xbxzPRgMduPWgKDAweqXk9SGX3mjqF0oyd5yhGTiU/jpHg6ZLXf//g45zJqRjfMnRi8I
vbEEAxUKyhPfbrQ8/byfq/isJHFiR0Vjr2U0HOqcctRgCTfrZr1b14jRVopjVqhk37ef
KapCbmTbBLznJLQH6jfi4LvKpSlJDW6l7R/CC4WtAzgcmHyA9nfjM4+egLg15giMpn3a
549c+jYBFgsjblhmyFw05dGSpUvP+jJeKTcFnlZe6yU7Qjnqhs6TlV/Jm8HAkPH1zdS5
XDAw==;
darn=xinu.tv
ARC-Authentication-Results: i=2; mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=RjBRlfFL;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=HaiL0lRL;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1756408272; x=1757013072;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature:delivered-to
:x-forwarded-for:x-forwarded-to:x-gm-message-state:from:to:cc
:subject:date:message-id:reply-to;
bh=lgr/fFBrye/qM438Us9TAp1/DYWNuYxn2NUL4vzX/SU=;
b=VJaqGIpPE1gxGhbAl1Np3yZR/0QPEs/C6KtFdnsaH9ubxFrDOeF4uIygqAUN9YFmll
YZsN4G0iexB097atKRIXLrreE3pH3cOY56ym94fWRZGythS0MRZlw40QoHLLf3joTC6D
WHtaNcea0hO3V6l/6gKlOffJ/cv2GnyPi0Sv7neOC5v18VTxZwZn+Wp+pTPpWFcmvQ4J
IMSV0vNgIRrYJaItUt1d59B9Ah+0bcyd7jJ0TDRVvN97S8iSlSIw6NMwxjZMuyJSWO7X
5zm8xA+H+L8+pLMmGKfdBYxhNo/ibdwda+w/ECKIjdnFtbreGbYLsUnkLdPeumQ6LXs/
Q2mg==
X-Forwarded-Encrypted: i=2; AJvYcCXpJ2X9EF2q2d4efhhe9B8o7LcuPPe25tZZwgkhfxerDzSbY0obB8Eik41xltO5i7k4ANaJKQ==@xinu.tv
X-Gm-Message-State: AOJu0Yz5+coY8ftW9IS5OD7ZbkwXnD43Mcp5BZjn5I2cv4v+u+ilxOi+
0DKABW1HVFh3MqQ/Z9nU+svpDl4kHa5lTr5siCXHTf0Wpo4LT3UsILyLUvwua0tsx9da14Gl6Fb
R1xVSmax6VR4PgZzrnOKZZx1x1re2RaTFGMAaA0Ei5ua3bZpn8axccwggYc94Jw==
X-Received: by 2002:a2e:a984:0:b0:336:7b24:2af7 with SMTP id 38308e7fff4ca-3367b242dd2mr36540291fa.17.1756408271464;
Thu, 28 Aug 2025 12:11:11 -0700 (PDT)
X-Forwarded-To: gmail@xinu.tv
X-Forwarded-For: couchmoney@gmail.com gmail@xinu.tv
Delivered-To: couchmoney@gmail.com
Received: by 2002:a05:6504:955:b0:2b8:eb6f:82ec with SMTP id k21csp1133490lts;
Thu, 28 Aug 2025 12:11:10 -0700 (PDT)
X-Received: by 2002:a05:6602:3c3:b0:86d:9ec7:267e with SMTP id ca18e2360f4ac-886bd155520mr3955796839f.4.1756408269941;
Thu, 28 Aug 2025 12:11:09 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1756408269; cv=none;
d=google.com; s=arc-20240605;
b=Gvk+jquchLt+hySEph55datOhigiuAMXW4mgi5vTVp51rzJ7PB+rH7vx23tj1QAB+0
RIOZTaB67H8yFXwAUNZWd1GMnpocZR+tI4bMxbKzDYd7zgaTzSSa2InDROhqOhHqBpX8
eWD23F+xRon/qEYQd0YEjZVt20WvKzpvjbpvCyWpq7Z4y376KoJArxsspsKZlALrCfKq
cyt9B/EKr3ZmAzRiswiH7KY/iHd1qYgtYy0tYGNtjU0nZ+5fK/tVlw+lJuLtt+aA+ZCy
o5y8Y5/thdSJsT159u+bV5eICZWC5kGnztNsXg0Nr2H22XzUC1epWZvJkZW2j+SXQm5k
Wdew==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature;
bh=lgr/fFBrye/qM438Us9TAp1/DYWNuYxn2NUL4vzX/SU=;
fh=mbzrMIWIgWMC0ni1xEx+ViW4J0RLAdLdPT2cX81nTlk=;
b=hra/E01IWuIFrWtk3uTcoj04apbHeQcQBSINqYDpr3cO7rXknIvpeXoWLvk0EIJI5y
syt60ekwVnsX/qb2F1HbN896dm97QrEGIwAiJyN2oTFauLoYObpcuhPS317hU4+YubO+
RLUntXsPK2qiifmPCOMPD6wACQB9YXpOPHrrl5x/yZlria1Tfg3XQcZIYsWcU/Qil94x
GtK+i82uzPXEQ0fVieEgJaZtmrW7OFEpPjd1KGp6sYtGvOxUfxVKl5MhLrCqfcLN9fd7
Xren0S32b/IsZA8ASdFca3CNjaAL2Ajlatb39XN17txnKrpQje/ReiVkm9wwo194NwCp
3dfQ==;
dara=google.com
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=RjBRlfFL;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=HaiL0lRL;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
Received: from mail-sor-f73.google.com (mail-sor-f73.google.com. [209.85.220.73])
by mx.google.com with SMTPS id ca18e2360f4ac-88711b2248fsor90547939f.5.2025.08.28.12.11.09
for <couchmoney@gmail.com>
(Google Transport Security);
Thu, 28 Aug 2025 12:11:09 -0700 (PDT)
Received-SPF: pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) client-ip=209.85.220.73;
Authentication-Results: mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=RjBRlfFL;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=HaiL0lRL;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=google.com; s=20230601; t=1756408269; x=1757013069; dara=google.com;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:from:to:cc:subject:date:message-id:reply-to;
bh=lgr/fFBrye/qM438Us9TAp1/DYWNuYxn2NUL4vzX/SU=;
b=RjBRlfFLVsAeeTCwo5Z3c1Y5G+pvz4XSTyHiVKUHmxClmpM30ZeHTVLl36njuM/7rx
mFwbzGk80zXgGpZyc7qnhSIVxXeMv4iex2UIc1D7Rcw3CF4q/HPlulcD9uVnsxRvng5Z
6PVcBQH3qGn0zvDDb0QHEcuDed4sNd/4wkYMOchxlp1TfdrbMZdCI+EXwTyvGgbVjd+/
erPyF5JZL/UJx7+gWoXSE7yJkPQrKYiv4LApu0STV4iSOEL8XsTQ4nZiZHSLeeKr0y7w
TUWhjfOCgD/YTZW5PTuFBW+lI03Ny19iGHbQNwKrLLcGwW7TJ2PYBR90vsIfaJtG5RM6
MP1w==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20230601; t=1756408269; x=1757013069; dara=google.com;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:from:to:cc:subject:date:message-id:reply-to;
bh=lgr/fFBrye/qM438Us9TAp1/DYWNuYxn2NUL4vzX/SU=;
b=HaiL0lRLeUjb1Rw8g5U5npEElUjhuKY2dPzOaldvum7ZqfY26X35u8SQTxCXWcSsGp
RKrlHykB6fjjPSjSGBB+uKe98anrorlvgkhUluES0LzmAZ6STVlPUfPHb/RreJQ7Ol1r
N7oNIEg5EnGia1g6rWliSMHY7Fb4sQzMaS2P+qhtq0OFzB6F57atJAwTUWaspDHycfdh
S8ji+q7DEiLq1LfXIxj+WwenT/iRFIJsfmvXsHgQiKMoYGdENfAGZPdo7W0sTEK3TkWz
xFOny/4bQmx/49F4C1HnLsHoBi0j6sezIQsc+U83vvChFXXrELQrK5PiJL+UOCLZo48R
RJDQ==
X-Google-Smtp-Source: AGHT+IG3ta6ofCYBa0SfJ7K3lq1EjsCnjr+BZDRz/SVLQfyo54CcUFgE5iTTB5E+h//QXT9iTojhKpMp6QZ4QB+5HAcs
MIME-Version: 1.0
X-Received: by 2002:a05:6602:1544:b0:887:6a2:6054 with SMTP id
ca18e2360f4ac-88706a263famr584022039f.9.1756408269509; Thu, 28 Aug 2025
12:11:09 -0700 (PDT)
Reply-To: tconvertino@gmail.com
Sender: Google Calendar <calendar-notification@google.com>
Auto-Submitted: auto-generated
Message-ID: <calendar-8ecdd8ef-29ed-4f61-857d-1215ab585aba@google.com>
Date: Thu, 28 Aug 2025 19:11:09 +0000
Subject: New event: Dentist appt @ Tue Sep 23, 2025 3pm - 4pm (PDT) (tconvertino@gmail.com)
From: tconvertino@gmail.com
To: couchmoney@gmail.com
Content-Type: multipart/alternative; boundary="000000000000fc1bff063d71aa4b"
X-Spamd-Result: default: False [-0.80 / 15.00];
ARC_ALLOW(-1.00)[google.com:s=arc-20240605:i=2];
URI_COUNT_ODD(1.00)[1];
DMARC_POLICY_ALLOW(-0.50)[gmail.com,none];
R_DKIM_ALLOW(-0.20)[google.com:s=20230601,gmail.com:s=20230601];
R_SPF_ALLOW(-0.20)[+ip6:2a00:1450:4000::/36];
MIME_BASE64_TEXT(0.10)[];
MANY_INVISIBLE_PARTS(0.10)[2];
MIME_GOOD(-0.10)[multipart/alternative,text/plain];
FREEMAIL_TO(0.00)[gmail.com];
RCVD_COUNT_THREE(0.00)[3];
FORGED_SENDER(0.00)[tconvertino@gmail.com,couchmoney@gmail.com];
FROM_NEQ_ENVFROM(0.00)[tconvertino@gmail.com,couchmoney@gmail.com];
MIME_TRACE(0.00)[0:+,1:+,2:~];
FREEMAIL_ENVFROM(0.00)[gmail.com];
RCPT_COUNT_ONE(0.00)[1];
FREEMAIL_REPLYTO(0.00)[gmail.com];
FREEMAIL_FROM(0.00)[gmail.com];
URIBL_BLOCKED(0.00)[mail-lj1-x230.google.com:rdns,mail-lj1-x230.google.com:helo];
TAGGED_FROM(0.00)[caf_=gmail=xinutv];
HAS_REPLYTO(0.00)[tconvertino@gmail.com];
NEURAL_HAM(-0.00)[-0.995];
FWD_GOOGLE(0.00)[couchmoney@gmail.com];
TO_DN_NONE(0.00)[];
FORGED_SENDER_FORWARDING(0.00)[];
RCVD_TLS_LAST(0.00)[];
TO_DOM_EQ_FROM_DOM(0.00)[];
FROM_NO_DN(0.00)[];
ASN(0.00)[asn:15169, ipnet:2a00:1450::/32, country:US];
DKIM_TRACE(0.00)[google.com:+,gmail.com:+];
MISSING_XM_UA(0.00)[];
REPLYTO_EQ_FROM(0.00)[]
X-Rspamd-Server: phx
X-Rspamd-Action: no action
X-Rspamd-Queue-Id: B4E848B007
X-TUID: eMNiZ49uiDPB
--000000000000fc1bff063d71aa4b
Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes
Content-Transfer-Encoding: base64
RGVudGlzdCBhcHB0DQpUdWVzZGF5IFNlcCAyMywgMjAyNSDii4UgM3BtIOKAkyA0cG0NClBhY2lm
aWMgVGltZSAtIExvcyBBbmdlbGVzDQoNCg0KDQpPcmdhbml6ZXINCnRjb252ZXJ0aW5vQGdtYWls
LmNvbQ0KdGNvbnZlcnRpbm9AZ21haWwuY29tDQoNCn5+Ly9+fg0KSW52aXRhdGlvbiBmcm9tIEdv
b2dsZSBDYWxlbmRhcjogaHR0cHM6Ly9jYWxlbmRhci5nb29nbGUuY29tL2NhbGVuZGFyLw0KDQpZ
b3UgYXJlIHJlY2VpdmluZyB0aGlzIGVtYWlsIGJlY2F1c2UgeW91IGFyZSBzdWJzY3JpYmVkIHRv
IGNhbGVuZGFyICANCm5vdGlmaWNhdGlvbnMuIFRvIHN0b3AgcmVjZWl2aW5nIHRoZXNlIGVtYWls
cywgZ28gdG8gIA0KaHR0cHM6Ly9jYWxlbmRhci5nb29nbGUuY29tL2NhbGVuZGFyL3Ivc2V0dGlu
Z3MsIHNlbGVjdCB0aGlzIGNhbGVuZGFyLCBhbmQgIA0KY2hhbmdlICJPdGhlciBub3RpZmljYXRp
b25zIi4NCg0KRm9yd2FyZGluZyB0aGlzIGludml0YXRpb24gY291bGQgYWxsb3cgYW55IHJlY2lw
aWVudCB0byBzZW5kIGEgcmVzcG9uc2UgdG8gIA0KdGhlIG9yZ2FuaXplciwgYmUgYWRkZWQgdG8g
dGhlIGd1ZXN0IGxpc3QsIGludml0ZSBvdGhlcnMgcmVnYXJkbGVzcyBvZiAgDQp0aGVpciBvd24g
aW52aXRhdGlvbiBzdGF0dXMsIG9yIG1vZGlmeSB5b3VyIFJTVlAuDQoNCkxlYXJuIG1vcmUgaHR0
cHM6Ly9zdXBwb3J0Lmdvb2dsZS5jb20vY2FsZW5kYXIvYW5zd2VyLzM3MTM1I2ZvcndhcmRpbmcN
Cg==
--000000000000fc1bff063d71aa4b
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
<!doctype html><html xmlns=3D"http://www.w3.org/1999/xhtml" xmlns:v=3D"urn:="...truncated for brevity...

View File

@ -1,175 +0,0 @@
Return-Path: <couchmoney+caf_=gmail=xinu.tv@gmail.com>
Delivered-To: bill@xinu.tv
Received: from phx.xinu.tv [74.207.253.222]
by nixos-01.h.xinu.tv with IMAP (fetchmail-6.5.1)
for <wathiede@localhost> (single-drop); Thu, 11 Sep 2025 12:27:35 -0700 (PDT)
Received: from phx.xinu.tv
by phx.xinu.tv with LMTP
id CqRrBqciw2hiKicAJR8clQ
(envelope-from <couchmoney+caf_=gmail=xinu.tv@gmail.com>)
for <bill@xinu.tv>; Thu, 11 Sep 2025 12:27:35 -0700
X-Original-To: gmail@xinu.tv
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=2a00:1450:4864:20::130; helo=mail-lf1-x130.google.com; envelope-from=couchmoney+caf_=gmail=xinu.tv@gmail.com; receiver=xinu.tv
Authentication-Results: phx.xinu.tv;
dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20230601 header.b=dc+iKaXd;
dkim=pass (2048-bit key; unprotected) header.d=gmail.com header.i=@gmail.com header.a=rsa-sha256 header.s=20230601 header.b=kf8o8wAd
Received: from mail-lf1-x130.google.com (mail-lf1-x130.google.com [IPv6:2a00:1450:4864:20::130])
by phx.xinu.tv (Postfix) with ESMTPS id D7E2D80037
for <gmail@xinu.tv>; Thu, 11 Sep 2025 12:27:33 -0700 (PDT)
Received: by mail-lf1-x130.google.com with SMTP id 2adb3069b0e04-55f716e25d9so1141446e87.1
for <gmail@xinu.tv>; Thu, 11 Sep 2025 12:27:33 -0700 (PDT)
ARC-Seal: i=2; a=rsa-sha256; t=1757618852; cv=pass;
d=google.com; s=arc-20240605;
b=MZ+1JfQuPR9luCCxiZNUeqSEpjt1vLuM3bTRCaal/W0NBxkCH0y5v9WfPR0KJ2BPb1
Rtnt/5ayDtmsLf8l6yTTVsBlFYW70ehqXWMD10MMcDEMvnib4KKDAacGaSmijAK4cYGq
FOU9CGNY986OMXMk54TD9NF3fkKDIKcAoh81D6at5/DE3Puuxofq0vZmtmVqQBNKG169
REkhcDpkXTMs/4rJpmZwXp2HbjD84avusBwSlYIQUWsBgO4g7THHjoR4Uk56cek9aEds
ip8IkTO6KRFe6u8FebQsZ/Q9sSAK3pheMExWFVMha9Y0XhACVOZiV600zRCPS9MNHhYw
XEaA==
ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature:delivered-to;
bh=mVNsDGUAhSGrAIoTy8PIfvCBxBB4yaBy/VZH8i3gPl4=;
fh=WnbwIlqFRbBot/H7TyqablNBDgXRuegsgjC3piothTI=;
b=aYMo5f7VI2b4CiAvLELRJ9zM3dF7ZH8FEqmoAtCcfPHrT9kLLCnriuyXG1R6sC3eoR
++boT29xoScVroIlfcI77Ty7N5X1fawOABkVDWWt7z5w4WhiesT0klxw5nINj9hnLBiK
22nrMevpRpFtmuDO7cle78lSAFZoZuyv+aXCK9RnLKvIm2JuXRrvU8LivxbbpNB4gNl0
hE1jsGuZm1SOJ54SRLwwa4HpSiOJV2x2txTtPCzmvE/LZvNESPjfi3Y2u7gaR87OzkNs
gNi5Xoc+D908zBsmcYKpUYiQcPL79s3DfNwYFIs/rR8Z2xgaHbFD/YmqRUmCEeNLv7o2
RR8g==;
darn=xinu.tv
ARC-Authentication-Results: i=2; mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=dc+iKaXd;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=kf8o8wAd;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1757618852; x=1758223652;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature:delivered-to
:x-forwarded-for:x-forwarded-to:x-gm-message-state:from:to:cc
:subject:date:message-id:reply-to;
bh=mVNsDGUAhSGrAIoTy8PIfvCBxBB4yaBy/VZH8i3gPl4=;
b=GKJkb+LmE79XIMEhHRvoCodKS+GBTOCShzMe06Q+zKxUZFHi6XMg8GqteuXQO9LVbw
nPUVN4QO2Hvqch0xzjbc0ryyMOD0u7HqpDUAEZCzamFXIfsX6hZXKLhFqy4YomtsG3os
TCOWBGLqwu7KalfOVg2p+csOR68i0mGyBII1sKcL9vUv9kIQJZxQKHGkuIc48cf6tbUB
L+mkVbMwXLSbpuTJszPmIVZV5o0K52KN+2QoLcmXGfw0mUOnjNI0oSovdbPg4SSDZ3cw
iIsC9vjvtCSFS3pf+Fp807s+Zjh5P6xeSxGU57qhC+HT9kTzIioh5EqKnGqcskDTqrI1
uCiQ==
X-Forwarded-Encrypted: i=2; AJvYcCUfSSA2sT31daRt2+W7dAD9YPx1gqa4JFpVuqCtxVtjqbKfKhOX/EcDQiECQ4BEWjmAP+IqTQ==@xinu.tv
X-Gm-Message-State: AOJu0Ywn7D0BjTaGiM/UFG0WhGuyYGfpLijg+ouhrOaGZzSREyTcRa37
XA3bzQ/LKTpzWhhh01GMwnigmELbWdIVr/BeRLVCuJdh+m+JBMgnAjBTIDs9RF3/xfR7rpG7VOB
6k+ugF+8QRKB4BcL2t8MvfJD03CkrzuhhvUtFTRHopcSZrkqzh8GOJayq42VveQ==
X-Received: by 2002:a05:6512:3b24:b0:55f:6580:818c with SMTP id 2adb3069b0e04-57050fe2fa3mr165340e87.46.1757618851553;
Thu, 11 Sep 2025 12:27:31 -0700 (PDT)
X-Forwarded-To: gmail@xinu.tv
X-Forwarded-For: couchmoney@gmail.com gmail@xinu.tv
Delivered-To: couchmoney@gmail.com
Received: by 2002:a05:6504:d09:b0:2c3:f6c4:ad72 with SMTP id c9csp3388833lty;
Thu, 11 Sep 2025 12:27:29 -0700 (PDT)
X-Received: by 2002:a05:6602:36ce:b0:889:b536:779b with SMTP id ca18e2360f4ac-8903378d714mr78653239f.7.1757618849269;
Thu, 11 Sep 2025 12:27:29 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1757618849; cv=none;
d=google.com; s=arc-20240605;
b=Ln2bufZfSNhR/NmMPrG2QFdtvupjJtLDQnFvsL8HTPn+Dlrt5ff+6k6Wpupab/5mS7
hXjtVD0jnryGUiM5h+SNjxwzNPM3PBoueTpAzzBkjHQqMxJVpspgsGJUVOWAVRBWtWo
39qFyoP0vhzGRWDAuAFV+4VDhsvH7GL8lTrZCSMzrngTadmEdJ5haUIQOa50KFUn5HrK
1r12gayb+TaGaWfQfDo0Me689T8MQnS0ITUuzgvFxfgHZBz3h+IPnC0hrlhdziGovETo
GvHzgCCtiVzu6rop6VMLjLuAYmmT9+jZ3GjSRb+078C9cJR17YpguOC14Cyv4od1Tf7y
RFiQ==;
dara=google.com
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature;
bh=mVNsDGUAhSGrAIoTy8PIfvCBxBB4yaBy/VZH8i3gPl4=;
fh=mbzrMIWIgWMC0ni1xEx+ViW4J0RLAdLdPT2cX81nTlk=;
b=JRkHr3CKSkCrafdLzBRtaBOGNl3/0ZSTtgubaNXtvhAiIqRqiQYocfLnVM6N/9sH7O
byTXYaRoaRLw/35WM+QTFGP3zUGRkM3eO4UVS/utVIss1IVLDjfmZHalqLYl8RokW5br
89Z/xYIyjTE7WUdy6uMSrExCNm5VWjO/qcMKsE5s5oDbXdSLaUYxLTurICM3LQksGkCY
wiAWaDDqK14+uhEhW5AyEnebDSYhL9U8UadIv+eK6Ng9q1kwOUzxICRQXEyUtnKhaDKJ
eZ1Qe1mp1CjCulr+I15fz3VwUJ6W1cv6cytcxPbu4p5GPn2gb2hS1eR81HVTL6V1Sp5G
NdDQ==;
dara=google.com
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=dc+iKaXd;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=kf8o8wAd;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
Received: from mail-sor-f73.google.com (mail-sor-f73.google.com. [209.85.220.73])
by mx.google.com with SMTPS id ca18e2360f4ac-88f2ea1122asor117632339f.3.2025.09.11.12.27.29
for <couchmoney@gmail.com>
(Google Transport Security);
Thu, 11 Sep 2025 12:27:29 -0700 (PDT)
Received-SPF: pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) client-ip=209.85.220.73;
Authentication-Results: mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=dc+iKaXd;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=kf8o8wAd;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=google.com; s=20230601; t=1757618849; x=1758223649; dara=google.com;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:from:to:cc:subject:date:message-id:reply-to;
bh=mVNsDGUAhSGrAIoTy8PIfvCBxBB4yaBy/VZH8i3gPl4=;
b=dc+iKaXdFyqu6K0MIgk848QuwpQXvwzwlEVkxmjuCWvn9DzanMbYn5QJRyRTKilRna
BZ7gJSPriHUHcJd4fVKgGuCaQg0TxenCwm+0R64oB1xcDLfonayo/nCrFqEcCLHNmi7x
lTyWGJ0rLw6nKazxtcCdIbDhVgiE7/fXNI89w6XFp6pcKLl48yFIoCG1f6uY4iQ7QqNU
hLHzjmlzjTi58xFLao7SizZ0lr7E5cHXKHp1Ls/hkDzzcY0Y+O5+3r+NQw4MtpHTcY6/
kQlg6OhyMx8PTu4cuepQKXLHV4aFaNJbDQTp8wew4xPIgi7pm2p6hb6C3GgwY6ptOvLd
wuag==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20230601; t=1757618849; x=1758223649; dara=google.com;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:from:to:cc:subject:date:message-id:reply-to;
bh=mVNsDGUAhSGrAIoTy8PIfvCBxBB4yaBy/VZH8i3gPl4=;
b=kf8o8wAd5DSU/NC7SDiuIoohCu+/7wTjWyQqDYbBjUFGaBaYdj6aD5JWNQ1KEA2W8o
E+Qy2ymyrzodKa1eOsQX2UDAYKOKpdxMWvx1u19+SC3Dp8DP4puRMrL2ObiSEMLCuOvz
Mxmkd+ZUP72EhVuQwK1iSm04/cjQaMsSiPhvSBaxXMaaarwlKeOoCoIo+qC/Z9emiBBv
Gk0sQcLA+CByvsxuvD9GInSA0rdoZ0ijhSb0Y475Hieam1QQqy/fhe8lgujzhXNFoIbR
5EA9GE0VV9PDoNanaT+u954YeOFBL2YZ5gm2gHltw8tBI98LKnC42Pa3qyMznBa2dI2Q
A0RQ==
X-Google-Smtp-Source: AGHT+IGmC5/03nTVMeYJBoq1R/BiA19iH0DFaZyyImB3W8mtgjdn+XqIFK1fC8aTwWRXQmsr71Xo0cmkgx6hjPvicQ/d
MIME-Version: 1.0
X-Received: by 2002:a05:6602:380d:b0:887:4c93:f12c with SMTP id
ca18e2360f4ac-8903596aca3mr58994639f.17.1757618848817; Thu, 11 Sep 2025
12:27:28 -0700 (PDT)
Reply-To: tconvertino@gmail.com
Sender: Google Calendar <calendar-notification@google.com>
Auto-Submitted: auto-generated
Message-ID: <calendar-01d5e8a0-fad7-450b-9758-a16472bf2aa8@google.com>
Date: Thu, 11 Sep 2025 19:27:28 +0000
Subject: Canceled event: Scout Babysits @ Thu Sep 11, 2025 6pm - 9pm (PDT) (Family)
From: tconvertino@gmail.com
To: couchmoney@gmail.com
Content-Type: multipart/mixed; boundary="000000000000226b77063e8b878d"
--000000000000226b77063e8b878d
Content-Type: text/calendar; charset="UTF-8"; method=CANCEL
Content-Transfer-Encoding: 7bit
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:CANCEL
X-GOOGLE-CALID:g66m0feuqsao8l1c767pvvcg4k@group.calendar.google.com
BEGIN:VEVENT
DTSTART:20250912T010000Z
DTEND:20250912T040000Z
DTSTAMP:20250911T192728Z
UID:4ang6172d1t7782sn2hmi30fgi@google.com
CREATED:20250901T224707Z
DESCRIPTION:
LAST-MODIFIED:20250911T192728Z
LOCATION:
SEQUENCE:1
STATUS:CANCELLED
SUMMARY:Scout Babysits
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR
--000000000000226b77063e8b878d--

View File

@ -1,169 +0,0 @@
Return-Path: <couchmoney+caf_=gmail=xinu.tv@gmail.com>
Delivered-To: bill@xinu.tv
Received: from phx.xinu.tv [74.207.253.222]
by nixos-01.h.xinu.tv with IMAP (fetchmail-6.4.39)
for <wathiede@localhost> (single-drop); Mon, 02 Jun 2025 07:06:34 -0700 (PDT)
Received: from phx.xinu.tv
by phx.xinu.tv with LMTP
id qDo+FuqvPWh51xIAJR8clQ
(envelope-from <couchmoney+caf_=gmail=xinu.tv@gmail.com>)
for <bill@xinu.tv>; Mon, 02 Jun 2025 07:06:34 -0700
X-Original-To: gmail@xinu.tv
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=2a00:1450:4864:20::130; helo=mail-lf1-x130.google.com; envelope-from=couchmoney+caf_=gmail=xinu.tv@gmail.com; receiver=xinu.tv
Authentication-Results: phx.xinu.tv;
dkim=pass (2048-bit key; unprotected) header.d=google.com header.i=@google.com header.a=rsa-sha256 header.s=20230601 header.b=zT2yUtVH;
dkim=pass (2048-bit key; unprotected) header.d=gmail.com header.i=@gmail.com header.a=rsa-sha256 header.s=20230601 header.b=nmJW8N67
Received: from mail-lf1-x130.google.com (mail-lf1-x130.google.com [IPv6:2a00:1450:4864:20::130])
by phx.xinu.tv (Postfix) with ESMTPS id 912AC80034
for <gmail@xinu.tv>; Mon, 02 Jun 2025 07:06:32 -0700 (PDT)
Received: by mail-lf1-x130.google.com with SMTP id 2adb3069b0e04-54e7967cf67so5267078e87.0
for <gmail@xinu.tv>; Mon, 02 Jun 2025 07:06:32 -0700 (PDT)
ARC-Seal: i=2; a=rsa-sha256; t=1748873190; cv=pass;
d=google.com; s=arc-20240605;
b=W3s0wT+CV1W21AldY9lfxPlKRbc7XMoorEnilNq5iGjlw18vDM6eFPb+btqaGAPOPe
CMyGeinsFPuql+S7u6HgjZcf9ZFH71sKoFoQytm30hAXB76GO06qi1jRW6o0miuGt/j/
bb8qWAiAsGr34mHIbE5fBdkNOGcqW85oI78GolLqpROgn/42boEYxiGAQjybPtO4L84J
wP2RBkHiQQGXUjL6b02tozCji1w2XdfYqtW8RteUs1pqYdXl4GUilMLt5C0d2bhSGksS
3tMTFjuycbaj+F6QFCkQfEsHx/I7GjuD4mToLcYpzrNnmZZUidAoKuh+uin0cEVvnQ1j
V8aA==
ARC-Message-Signature: i=2; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature:delivered-to;
bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=;
fh=5zy5Gi9ngAea7dC9ZKKPh/BZlFmotJq74g9KHrEIwaE=;
b=QTAjqit0gYnuGa1lbO9RUXOVpyutliNo+tG6irWFsjGhnvMkis2KdLb6saYPnLCG7F
rSRXvw0HwuaJfXAV3XvIT0pxTg3PXYnc8kt/F8OtG+LiakJbMV1soj8OJ+5lZPKFmvna
i2T5mJjEknZsc9qWYmaAEVqIg71jhPH5CjJyehNhsIJ1/O9CH4VF8L0yv9KUMAA4tzog
LfI+SpOE2z/wYuMDxi2Ld3FgaVCQgkMM2Tlys8P0DjCaewWeaZFmZKIEEZUbKWbrivTa
RSO+Us+9yrt8hDdJuvtf9eXsGvuZtdj/2APRts/0cd7SFAQqRd0DnhGIHoXR74YVHaqi
U7IQ==;
darn=xinu.tv
ARC-Authentication-Results: i=2; mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=zT2yUtVH;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=nmJW8N67;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1748873190; x=1749477990;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature:delivered-to
:x-forwarded-for:x-forwarded-to:x-gm-message-state:from:to:cc
:subject:date:message-id:reply-to;
bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=;
b=dBjp6JdmFUj0jKPDo9r2/xvfVSvxKaF15UYwYU7itdM18qpCnrgQdHMP2ST7EQBxou
58yZfVjrx84gg9phedpVSg4SaBaPIhXsLuUeVQZtPd7J3WYiH4+OGcecjV+cD0dG0TUi
o/FbZULNl3REysvoAj+AwUL/ny2FnNU4PIhkeSq+d6iNztkexIKLS8qWqHosenPlVX+E
Z7OGQZpK6m1LB5UbCsaODQq5wbNIxlOxqTP1rCHe/hHk53ljiNegzaOS31mVvp1n8/g1
pWIZltyZORs0zi6U9+mNd9ZbaeQjHqBrcb2bsTxCD+u0DBuF2RjLguS/feaB25TG8LAg
szYg==
X-Forwarded-Encrypted: i=2; AJvYcCXfGRAIDqrPsT1vzTMSiuMrlTj/DbRrr+8w7X+iLRH2XK/n8MZhV3UaT0Zia6c6jMrf3s3eHA==@xinu.tv
X-Gm-Message-State: AOJu0YxOQEmNiUg4NKf4NM1BgQMqTJaFM6txPnL6u74ff1dZvoSgTC4d
TtJJqfdHsajxloSGDsSPqIQ/M/Se/sfymEExFQxDXYA/XasA6+sdye/Ihl9QekGJK9jet1VtQ3r
dcg89xnFcxezg3ji6xH8jnSULlp350K9K7LR0LfTQqg6e/BEKEF8XDaNgmJC+RQ==
X-Received: by 2002:a05:6512:2246:b0:553:35bb:f7b7 with SMTP id 2adb3069b0e04-55342f92776mr2472199e87.32.1748873190333;
Mon, 02 Jun 2025 07:06:30 -0700 (PDT)
X-Forwarded-To: gmail@xinu.tv
X-Forwarded-For: couchmoney@gmail.com gmail@xinu.tv
Delivered-To: couchmoney@gmail.com
Received: by 2002:ab3:7457:0:b0:2b1:14e:dc2b with SMTP id g23csp2818972lti;
Mon, 2 Jun 2025 07:06:29 -0700 (PDT)
X-Received: by 2002:a05:6602:6a8b:b0:86c:f898:74b8 with SMTP id ca18e2360f4ac-86d0521552emr1082401939f.10.1748873188734;
Mon, 02 Jun 2025 07:06:28 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1748873188; cv=none;
d=google.com; s=arc-20240605;
b=d2PNXrTE3VYjml3FmbC5rBW6XnsyuyVO3lPyM6VoVKFcvZ7a8tDRB+sh1ibo0D5Nvg
3i/Qon0RV401WFb9NQf5P048wpj19G8bOGPZUKMioBZcSxkr1RwH/GW6GBvGS+d+iqbW
43KWc6Px7RGOEeYfp8D88CuJ/5kMcsLMfDV1FRHo6T+chVY6c9fQkHjRreSGQcFXglt5
yaCpFKkAODO7rSHl2OW2kQ6eGgR0tUjb95+jdZXoU0GS3119CBYK9n9UhNaeXHIk/Zyy
f08r4Ce/m3Y6ISr4ovXxDeYNpeeUN1HT3XVyCVQJHjfWrHypKTiOt4q6yBhCgOgZTXJq
pL5A==;
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:dkim-signature:dkim-signature;
bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=;
fh=mbzrMIWIgWMC0ni1xEx+ViW4J0RLAdLdPT2cX81nTlk=;
b=YiMakYeE05UctWy9sW90/a3l1Hk1pAPv0+fpk5vmWrADcMwwI8cHVqBp+Nxds5psWa
a/zrw9UlxV4HgjLUP+ella/pK8XxK+sitKg0IhPOntwKbq1KfTNheufh4HtWj5yWedHE
sO/dVs6z/EW/gWrfBK/3JMgsnz3HrHmaoJ6caCaGI6t5jHxEXI+eJc5zILY+n0MdivkX
tJOo0L1s/k6MAdyLr4/IVqpxdhXbUPq44twCBNheHd8T5w1DC9ZXcr54X79fW8Vzbm8/
A++H3gnZRGtOayRySYQl04LFLk4YsisdhsKuaJV+WKYCW58wQqJT04mrVkx+m96qr1q0
BQtw==;
dara=google.com
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=zT2yUtVH;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=nmJW8N67;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
Received: from mail-sor-f73.google.com (mail-sor-f73.google.com. [209.85.220.73])
by mx.google.com with SMTPS id ca18e2360f4ac-86d0213d491sor465078439f.8.2025.06.02.07.06.28
for <couchmoney@gmail.com>
(Google Transport Security);
Mon, 02 Jun 2025 07:06:28 -0700 (PDT)
Received-SPF: pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) client-ip=209.85.220.73;
Authentication-Results: mx.google.com;
dkim=pass header.i=@google.com header.s=20230601 header.b=zT2yUtVH;
dkim=pass header.i=@gmail.com header.s=20230601 header.b=nmJW8N67;
spf=pass (google.com: domain of tconvertino@gmail.com designates 209.85.220.73 as permitted sender) smtp.mailfrom=tconvertino@gmail.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com;
dara=pass header.i=@gmail.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=google.com; s=20230601; t=1748873188; x=1749477988; dara=google.com;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:from:to:cc:subject:date:message-id:reply-to;
bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=;
b=zT2yUtVHhNy5fFiy6YKzfYCQPlCnufAEoWmbvjvj7mFNYUlLJHZ5FUeNnDs06Z1icR
bSVtejKixrz4hjFh9KeKvV9EQNGU7UFgySwqdy6szm+sHZQj+iJAXy85A1QaL6+0Swup
2y8QsjVJ96uugM0SaAYZqe+lmLBk6zFWqkg0U37vgwOupAcNsNBd7tos7cxO5eK6Aops
FJjr9JAD+ddX03ngH9zfnvlNV/+qbmiP6Hs8OmaJtZof2GLucpHgqUpIdolCh7F72v4p
DibO4RShI/IQCw9ejZxhRPBPWQwIdOYLjD/sDunX63M4NCS/63jZfhwqsAVgtmN/cUGq
spHQ==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20230601; t=1748873188; x=1749477988; dara=google.com;
h=to:from:subject:date:message-id:auto-submitted:sender:reply-to
:mime-version:from:to:cc:subject:date:message-id:reply-to;
bh=dgRmOj3aABlB3SNw+xxlI8L9ugJFZ1WMJrtLw/W8tnA=;
b=nmJW8N67IylgMNprzzf/IC7V2r7xeY0+8Bl0KcAak6Xly+IhVv3nyccvgdKsp+8Ccd
NcikfVOtCsE3gTqviReUbTAKy7PyClAbBTEHC0Ne71549BN+v8zX64RpGDFJGX5pJMG5
r0Ak88nxzjWkvDLhlnHmWdt/NggdQEI6T7oP4VZo0f0/Ym7g1WJhSItfdIhSRDNzK3ed
WPRXUIb1sW3+N0My4Os6L4IA9kdRk5z0qpQxtsIL9N0dzv4q18q6eH3KfTzVPr59PsYT
uSgkWoLQZdfA70MMlIRU5CnGbVDRH4TO/ib433vIblOmtLTkQ4EaOTzncbs0tovVes4z
evsQ==
X-Google-Smtp-Source: AGHT+IETNpLvkLm7t8VAdDcEcVtxFCttPh/uVZhoQCRlhUNlx9bmg67olJiD9EOND8g0z43NnM8iK4FxezZondExIawx
MIME-Version: 1.0
X-Received: by 2002:a05:6602:4183:b0:864:4a1b:dfc5 with SMTP id
ca18e2360f4ac-86d052154eamr1431889339f.9.1748873188195; Mon, 02 Jun 2025
07:06:28 -0700 (PDT)
Reply-To: tconvertino@gmail.com
Sender: Google Calendar <calendar-notification@google.com>
Auto-Submitted: auto-generated
Message-ID: <calendar-093be1c9-5d94-4994-8bc5-7daa1cfae47b@google.com>
Date: Mon, 02 Jun 2025 14:06:28 +0000
Subject: New event: Tamara and Scout in Alaska @ Tue Jun 24 - Mon Jun 30, 2025 (tconvertino@gmail.com)
From: tconvertino@gmail.com
To: couchmoney@gmail.com
Content-Type: multipart/alternative; boundary="00000000000023c70606369745e9"
--00000000000023c70606369745e9
Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes
Content-Transfer-Encoding: base64
VGFtYXJhIGFuZCBTY291dCBpbiBBbGFza2ENClR1ZXNkYXkgSnVuIDI0IOKAkyBNb25kYXkgSnVu
IDMwLCAyMDI1DQoNCg0KDQpPcmdhbml6ZXINCnRjb252ZXJ0aW5vQGdtYWlsLmNvbQ0KdGNvbnZl
cnRpbm9AZ21haWwuY29tDQoNCn5+Ly9+fg0KSW52aXRhdGlvbiBmcm9tIEdvb2dsZSBDYWxlbmRh
cjogaHR0cHM6Ly9jYWxlbmRhci5nb29nbGUuY29tL2NhbGVuZGFyLw0KDQpZb3UgYXJlIHJlY2Vp
dmluZyB0aGlzIGVtYWlsIGJlY2F1c2UgeW91IGFyZSBzdWJzY3JpYmVkIHRvIGNhbGVuZGFyICAN
Cm5vdGlmaWNhdGlvbnMuIFRvIHN0b3AgcmVjZWl2aW5nIHRoZXNlIGVtYWlscywgZ28gdG8gIA0K
aHR0cHM6Ly9jYWxlbmRhci5nb29nbGUuY29tL2NhbGVuZGFyL3Ivc2V0dGluZ3MsIHNlbGVjdCB0
aGlzIGNhbGVuZGFyLCBhbmQgIA0KY2hhbmdlICJPdGhlciBub3RpZmljYXRpb25zIi4NCg0KRm9y
d2FyZGluZyB0aGlzIGludml0YXRpb24gY291bGQgYWxsb3cgYW55IHJlY2lwaWVudCB0byBzZW5k
IGEgcmVzcG9uc2UgdG8gIA0KdGhlIG9yZ2FuaXplciwgYmUgYWRkZWQgdG8gdGhlIGd1ZXN0IGxp
c3QsIGludml0ZSBvdGhlcnMgcmVnYXJkbGVzcyBvZiAgDQp0aGVpciBvd24gaW52aXRhdGlvbiBz
dGF0dXMsIG9yIG1vZGlmeSB5b3VyIFJTVlAuDQoNCkxlYXJuIG1vcmUgaHR0cHM6Ly9zdXBwb3J0
Lmdvb2dsZS5jb20vY2FsZW5kYXIvYW5zd2VyLzM3MTM1I2ZvcndhcmRpbmcNCg==
--00000000000023c70606369745e9
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
<!doctype html><html xmlns=3D"http://www.w3.org/1999/xhtml" xmlns:v=3D"urn:="...truncated for brevity...

View File

@ -1,57 +0,0 @@
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Pacific Standard Time
BEGIN:STANDARD
DTSTART:16010101T020000
TZOFFSETFROM:-0700
TZOFFSETTO:-0800
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:-0800
TZOFFSETTO:-0700
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
ORGANIZER;CN=Bill Thiede:mailto:wthiede@nvidia.com
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=Bill:mailt
o:couchmoney@gmail.com
DESCRIPTION;LANGUAGE=en-US:\n
UID:040000008200E00074C5B7101A82E00800000000A1458AEA8E4DDB01000000000000000
010000000988BC323BE65A8458B718B5EF8FE8152
SUMMARY;LANGUAGE=en-US:dentist night guard
DTSTART;TZID=Pacific Standard Time:20250108T080000
DTEND;TZID=Pacific Standard Time:20250108T090000
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20241213T184408Z
TRANSP:OPAQUE
STATUS:CONFIRMED
SEQUENCE:0
LOCATION;LANGUAGE=en-US:
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-OWNERAPPTID:2123132523
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:0
X-MICROSOFT-ONLINEMEETINGEXTERNALLINK:
X-MICROSOFT-ONLINEMEETINGCONFLINK:
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
X-MICROSOFT-ISRESPONSEREQUESTED:TRUE
X-MICROSOFT-LOCATIONS:[]
BEGIN:VALARM
DESCRIPTION:REMINDER
TRIGGER;RELATED=START:-PT5M
ACTION:DISPLAY
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@ -1,30 +0,0 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:REPLY
X-GOOGLE-CALID:g66m0feuqsao8l1c767pvvcg4k@group.calendar.google.com
BEGIN:VEVENT
DTSTART:20250813T010000Z
DTEND:20250813T030000Z
DTSTAMP:20250801T022550Z
ORGANIZER;CN=Family:mailto:g66m0feuqsao8l1c767pvvcg4k@group.calendar.google
.com
UID:6os3ap346th6ab9nckp30b9kc8sm2bb160q3gb9l6lgm6or160rjee1mco@google.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=superm
atute@gmail.com;X-NUM-GUESTS=0:mailto:supermatute@gmail.com
X-GOOGLE-CONFERENCE:https://meet.google.com/dcu-hykx-vym
CREATED:20250801T015712Z
DESCRIPTION:-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
:~:~:~:~:~:~:~:~::~:~::-\nJoin with Google Meet: https://meet.google.com/dc
u-hykx-vym\n\nLearn more about Meet at: https://support.google.com/a/users/
answer/9282720\n\nPlease do not edit this section.\n-::~:~::~:~:~:~:~:~:~:~
:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-
LAST-MODIFIED:20250801T022549Z
LOCATION:
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:[tenative] dinner w/ amatute
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR

View File

@ -1,9 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Multi-day Event
DTSTART;VALUE=DATE:20250828
DTEND;VALUE=DATE:20250831
DESCRIPTION:This event spans multiple days.
END:VEVENT
END:VCALENDAR

View File

@ -1,36 +0,0 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
DTSTART;VALUE=DATE:20250830
DTEND;VALUE=DATE:20250902
DTSTAMP:20250819T183713Z
ORGANIZER;CN=Bill Thiede:mailto:couchmoney@gmail.com
UID:37kplskaimjnhdnt8r5ui9pv7f@google.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
TRUE;CN=bill@xinu.tv;X-NUM-GUESTS=0:mailto:bill@xinu.tv
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE
;CN=Bill Thiede;X-NUM-GUESTS=0:mailto:couchmoney@gmail.com
X-MICROSOFT-CDO-OWNERAPPTID:1427505964
CREATED:20250819T183709Z
DESCRIPTION:
LAST-MODIFIED:20250819T183709Z
LOCATION:
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Test Straddle Month
TRANSP:TRANSPARENT
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:This is an event reminder
TRIGGER:-P0DT0H30M0S
END:VALARM
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:This is an event reminder
TRIGGER:-P0DT7H30M0S
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@ -1,13 +0,0 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test Recurring Event//EN
BEGIN:VEVENT
UID:recurring-test-1@example.com
DTSTART;VALUE=DATE:20250804
DTEND;VALUE=DATE:20250805
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250825T000000Z
SUMMARY:Test Recurring Event (Mon, Wed, Fri)
DESCRIPTION:This event recurs every Monday, Wednesday, and Friday in August 2025.
END:VEVENT
END:VCALENDAR

View File

@ -11,10 +11,6 @@ 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.42" build-info = "0.0.39"
letterbox-notmuch = { path = "../notmuch", version = "0.17", registry = "xinu" } letterbox-notmuch = { version = "0.8.1", path = "../notmuch", registry = "xinu" }
regex = "1.11.1" serde = { version = "1.0.147", features = ["derive"] }
serde = { version = "1.0.219", features = ["derive"] }
sqlx = "0.8.5"
strum_macros = "0.27.1"
tracing = "0.1.41"

View File

@ -1,14 +1,8 @@
use std::{ use std::hash::{DefaultHasher, Hash, Hasher};
convert::Infallible,
hash::{DefaultHasher, Hash, Hasher},
str::FromStr,
};
use build_info::{BuildInfo, VersionControl}; use build_info::{BuildInfo, VersionControl};
use letterbox_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 {
@ -19,20 +13,11 @@ pub struct SearchResult {
pub total: usize, pub total: usize,
} }
#[derive(Serialize, Deserialize, Debug, strum_macros::Display)] #[derive(Serialize, Deserialize, Debug)]
pub enum WebsocketMessage { pub struct Message {}
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}/")
@ -71,198 +56,3 @@ 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(".*", "")
}

View File

@ -9,50 +9,44 @@ repository.workspace = true
version.workspace = true version.workspace = true
[build-dependencies] [build-dependencies]
build-info-build = "0.0.42" build-info-build = "0.0.39"
[dev-dependencies] [dev-dependencies]
#wasm-bindgen-test = "0.3.50" wasm-bindgen-test = "0.3.33"
[dependencies] [dependencies]
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
log = "0.4.27" log = "0.4.17"
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.4", registry = "xinu" } console_log = { version = "0.1.0", registry = "xinu" }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.147", features = ["derive"] }
itertools = "0.14.0" itertools = "0.14.0"
serde_json = { version = "1.0.140", features = ["unbounded_depth"] } serde_json = { version = "1.0.93", features = ["unbounded_depth"] }
chrono = "0.4.40" chrono = "0.4.31"
graphql_client = "0.15.0" graphql_client = "0.14.0"
thiserror = "2.0.12" thiserror = "2.0.0"
gloo-net = { version = "0.6.0", features = ["json", "serde_json"] } gloo-net = { version = "0.6.0", features = ["json", "serde_json"] }
human_format = "1.1.0" human_format = "1.1.0"
build-info = "0.0.42" build-info = "0.0.39"
wasm-bindgen = "=0.2.100" wasm-bindgen = "=0.2.100"
uuid = { version = "1.16.0", features = [ uuid = { version = "1.13.1", features = [
"js", "js",
] } # direct dep to set js feature, prevents Rng issues ] } # direct dep to set js feature, prevents Rng issues
letterbox-shared = { path = "../shared/", version = "0.17", registry = "xinu" } letterbox-shared = { version = "0.8.1", path = "../shared", registry = "xinu" }
seed_hooks = { version = "0.4.1", registry = "xinu" } letterbox-notmuch = { version = "0.8.1", path = "../notmuch", registry = "xinu" }
strum_macros = "0.27.1" seed_hooks = { version = "0.1.13", registry = "xinu" }
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.77" version = "0.3.58"
features = [ features = [
"Clipboard", "Clipboard",
"DomRect", "DomRect",
"Element", "Element",
"History",
"MediaQueryList", "MediaQueryList",
"Navigator", "Navigator",
"Performance",
"ScrollRestoration",
"Window", "Window",
] ]

View File

@ -6,16 +6,9 @@ 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"

View File

@ -1,3 +0,0 @@
query CatchupQuery($query: String!) {
catchup(query: $query)
}

View File

@ -51,7 +51,7 @@
}, },
{ {
"args": [], "args": [],
"description": "Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its field be provided)", "description": "Indicates that an Input Object is a OneOf Input Object (and thus requires\n exactly one of its field be provided)",
"locations": [ "locations": [
"INPUT_OBJECT" "INPUT_OBJECT"
], ],
@ -107,14 +107,12 @@
} }
], ],
"mutationType": { "mutationType": {
"name": "MutationRoot" "name": "Mutation"
}, },
"queryType": { "queryType": {
"name": "QueryRoot" "name": "QueryRoot"
}, },
"subscriptionType": { "subscriptionType": null,
"name": "SubscriptionRoot"
},
"types": [ "types": [
{ {
"description": null, "description": null,
@ -316,16 +314,6 @@
"name": "Corpus", "name": "Corpus",
"possibleTypes": null "possibleTypes": null
}, },
{
"description": "Implement the DateTime<Utc> scalar\n\nThe input/output is a string in RFC3339 format.",
"enumValues": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"kind": "SCALAR",
"name": "DateTime",
"possibleTypes": null
},
{ {
"description": null, "description": null,
"enumValues": [ "enumValues": [
@ -683,30 +671,6 @@
} }
} }
}, },
{
"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,
@ -981,51 +945,6 @@
} }
} }
}, },
{
"args": [
{
"defaultValue": null,
"description": null,
"name": "query",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
{
"defaultValue": null,
"description": null,
"name": "wakeTime",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
}
}
],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "snooze",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
}
},
{ {
"args": [], "args": [],
"deprecationReason": null, "deprecationReason": null,
@ -1046,7 +965,7 @@
"inputFields": null, "inputFields": null,
"interfaces": [], "interfaces": [],
"kind": "OBJECT", "kind": "OBJECT",
"name": "MutationRoot", "name": "Mutation",
"possibleTypes": null "possibleTypes": null
}, },
{ {
@ -1349,45 +1268,6 @@
} }
} }
}, },
{
"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": [
{ {
@ -1531,33 +1411,6 @@
"name": "String", "name": "String",
"possibleTypes": null "possibleTypes": null
}, },
{
"description": null,
"enumValues": null,
"fields": [
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "values",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
}
}
],
"inputFields": null,
"interfaces": [],
"kind": "OBJECT",
"name": "SubscriptionRoot",
"possibleTypes": null
},
{ {
"description": null, "description": null,
"enumValues": null, "enumValues": null,

View File

@ -31,14 +31,6 @@ query ShowThreadQuery($threadId: String!) {
name name
addr addr
} }
xOriginalTo {
name
addr
}
deliveredTo {
name
addr
}
timestamp timestamp
body { body {
__typename __typename

View File

@ -1,4 +0,0 @@
mutation SnoozeMutation($query: String!, $wakeTime: DateTime!) {
snooze(query: $query, wakeTime: $wakeTime)
}

View File

@ -1,4 +1,4 @@
DEV_HOST=localhost DEV_HOST=localhost
DEV_PORT=9345 DEV_PORT=9345
graphql-client introspect-schema http://${DEV_HOST:?}:${DEV_PORT:?}/api/graphql/ --output schema.json graphql-client introspect-schema http://${DEV_HOST:?}:${DEV_PORT:?}/api/graphql --output schema.json
git diff schema.json git diff schema.json

View File

@ -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/7.0.1/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-2SwdPD6INVrV/lHTZbO2nodKhrnDdJK9/kg2XD1r9uGqPo1cUbujc+IYdlYdEErWNu69gVcYgdxlmVmzTWnetw==" 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 -->
@ -16,7 +16,6 @@
<link data-trunk rel="css" href="static/vars.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" /> <link data-trunk rel="css" href="static/overrides.css" />
<link data-trunk rel="css" href="static/email-specific.css" />
</head> </head>
<body> <body>

View File

@ -1,9 +1,7 @@
use chrono::Utc;
use gloo_net::{http::Request, Error}; use gloo_net::{http::Request, Error};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
type DateTime = chrono::DateTime<Utc>;
// The paths are relative to the directory where your `Cargo.toml` is located. // The paths are relative to the directory where your `Cargo.toml` is located.
// Both json and the GraphQL schema language are supported as sources for the schema // Both json and the GraphQL schema language are supported as sources for the schema
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
@ -14,14 +12,6 @@ type DateTime = chrono::DateTime<Utc>;
)] )]
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",
@ -54,14 +44,6 @@ pub struct AddTagMutation;
)] )]
pub struct RemoveTagMutation; pub struct RemoveTagMutation;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.json",
query_path = "graphql/snooze.graphql",
response_derives = "Debug"
)]
pub struct SnoozeMutation;
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
schema_path = "graphql/schema.json", schema_path = "graphql/schema.json",

View File

@ -11,7 +11,6 @@ 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.
@ -19,9 +18,6 @@ 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".

View File

@ -1,8 +1,6 @@
use std::collections::HashSet; use std::collections::HashSet;
use chrono::{DateTime, Utc};
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;
@ -13,7 +11,6 @@ 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
@ -32,21 +29,16 @@ pub fn unread_query() -> &'static str {
pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model { pub fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
let version = letterbox_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.request_url(url.clone()); orders.notify(subs::UrlRequested::new(url));
}; };
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(Msg::OnUrlChanged); orders.subscribe(on_url_changed);
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);
@ -61,20 +53,18 @@ 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(old: &Url, mut new: Url) -> Msg { fn on_url_changed(uc: subs::UrlChanged) -> Msg {
let did_change = *old != new; let mut url = uc.0;
let mut messages = Vec::new(); info!(
if did_change { "url changed '{}', history {}",
messages.push(Msg::ScrollToTop) url,
} history().length().unwrap_or(0)
let hpp = new.remaining_hash_path_parts(); );
let msg = match hpp.as_slice() { let hpp = url.remaining_hash_path_parts();
match hpp.as_slice() {
["t", tid] => Msg::ShowThreadRequest { ["t", tid] => Msg::ShowThreadRequest {
thread_id: tid.to_string(), thread_id: tid.to_string(),
}, },
@ -111,14 +101,11 @@ fn on_url_changed(old: &Url, mut new: Url) -> 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 => {
@ -144,7 +131,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.request_url(Url::current()); orders.perform_cmd(async move { on_url_changed(subs::UrlChanged(Url::current())) });
} }
Msg::Reload => { Msg::Reload => {
window() window()
@ -152,10 +139,7 @@ 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::OnUrlChanged(new_url) => { Msg::OnResize => (),
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 {
@ -197,7 +181,10 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}; };
} }
Msg::GoToSearchResults => { Msg::GoToSearchResults => {
orders.send_msg(Msg::SearchQuery(model.query.clone())); let url = urls::search(&model.query, 0);
info!("GoToSearchRestuls Start");
orders.request_url(url);
info!("GoToSearchRestuls End");
} }
Msg::UpdateQuery(query) => model.query = query, Msg::UpdateQuery(query) => model.query = query,
@ -260,29 +247,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::GoToSearchResults Msg::GoToSearchResults
}); });
} }
Msg::Snooze(query, wake_time) => {
let is_catchup = model.catchup.is_some();
orders.skip().perform_cmd(async move {
let res: Result<
graphql_client::Response<graphql::snooze_mutation::ResponseData>,
gloo_net::Error,
> = send_graphql(graphql::SnoozeMutation::build_query(
graphql::snooze_mutation::Variables {
query: query.clone(),
wake_time,
},
))
.await;
if let Err(e) = res {
error!("Failed to snooze {query} until {wake_time}: {e}");
}
if is_catchup {
Msg::CatchupMarkAsRead
} else {
Msg::GoToSearchResults
}
});
}
Msg::FrontPageRequest { Msg::FrontPageRequest {
query, query,
@ -291,7 +255,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
first, first,
last, last,
} => { } => {
model.refreshing_state = RefreshingState::Loading;
let (after, before, first, last) = match (after.as_ref(), before.as_ref(), first, last) let (after, before, first, last) = match (after.as_ref(), before.as_ref(), first, last)
{ {
// If no pagination set, set reasonable defaults // If no pagination set, set reasonable defaults
@ -316,33 +279,24 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
) )
}); });
} }
Msg::FrontPageResult(Err(e)) => { Msg::FrontPageResult(Err(e)) => error!("error FrontPageResult: {e:?}"),
let msg = format!("error FrontPageResult: {e:?}");
error!("{msg}");
model.refreshing_state = RefreshingState::Error(msg);
}
Msg::FrontPageResult(Ok(graphql_client::Response { Msg::FrontPageResult(Ok(graphql_client::Response {
data: None, data: None,
errors: None, errors: None,
.. ..
})) => { })) => {
let msg = format!("FrontPageResult no data or errors, should not happen"); error!("FrontPageResult no data or errors, should not happen");
error!("{msg}");
model.refreshing_state = RefreshingState::Error(msg);
} }
Msg::FrontPageResult(Ok(graphql_client::Response { Msg::FrontPageResult(Ok(graphql_client::Response {
data: None, data: None,
errors: Some(e), errors: Some(e),
.. ..
})) => { })) => {
let msg = format!("FrontPageResult error: {e:?}"); error!("FrontPageResult error: {e:?}");
error!("{msg}");
model.refreshing_state = RefreshingState::Error(msg);
} }
Msg::FrontPageResult(Ok(graphql_client::Response { Msg::FrontPageResult(Ok(graphql_client::Response {
data: Some(data), .. data: Some(data), ..
})) => { })) => {
model.refreshing_state = RefreshingState::None;
model.tags = Some( model.tags = Some(
data.tags data.tags
.into_iter() .into_iter()
@ -353,6 +307,7 @@ 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,
@ -382,7 +337,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
} }
Msg::ShowThreadRequest { thread_id } => { Msg::ShowThreadRequest { thread_id } => {
model.refreshing_state = RefreshingState::Loading;
orders.skip().perform_cmd(async move { orders.skip().perform_cmd(async move {
Msg::ShowThreadResult( Msg::ShowThreadResult(
send_graphql(graphql::ShowThreadQuery::build_query( send_graphql(graphql::ShowThreadQuery::build_query(
@ -395,7 +349,6 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::ShowThreadResult(Ok(graphql_client::Response { Msg::ShowThreadResult(Ok(graphql_client::Response {
data: Some(data), .. data: Some(data), ..
})) => { })) => {
model.refreshing_state = RefreshingState::None;
model.tags = Some( model.tags = Some(
data.tags data.tags
.into_iter() .into_iter()
@ -435,45 +388,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
orders.send_msg(Msg::WindowScrolled); orders.send_msg(Msg::WindowScrolled);
} }
Msg::ShowThreadResult(bad) => { Msg::ShowThreadResult(bad) => {
let msg = format!("show_thread_query error: {bad:#?}"); error!("show_thread_query error: {bad:#?}");
error!("{msg}");
model.refreshing_state = RefreshingState::Error(msg);
}
Msg::CatchupRequest { query } => {
model.refreshing_state = RefreshingState::Loading;
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), ..
})) => {
model.refreshing_state = RefreshingState::None;
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) => {
let msg = format!("catchup_query error: {bad:#?}");
error!("{msg}");
model.refreshing_state = RefreshingState::Error(msg);
} }
Msg::SelectionSetNone => { Msg::SelectionSetNone => {
if let Context::SearchResult { if let Context::SearchResult {
@ -588,11 +503,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
.expect("failed to copy to clipboard"); .expect("failed to copy to clipboard");
}); });
} }
Msg::ScrollToTop => {
web_sys::window().unwrap().scroll_to_with_x_and_y(0., 0.);
}
Msg::WindowScrolled => { Msg::WindowScrolled => {
// TODO: model.content_el doesn't go to None like it should when a DOM is recreated and the refrenced element goes away info!("WindowScrolled");
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()
@ -601,6 +513,7 @@ 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.));
@ -632,8 +545,7 @@ 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) => {
// Only git versions contain dash, don't autoreload there if version != model.versions.client {
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
@ -642,124 +554,8 @@ 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 => {
if let Some(thread_id) = current_thread_id(&model.context) {
if let Context::ThreadResult {
thread:
ShowThreadQueryThread::EmailThread(ShowThreadQueryThreadOnEmailThread {
messages,
..
}),
..
} = &model.context
{
//orders.send_msg(Msg::SetUnread(thread_id, false));
let unread_messages: Vec<_> = messages
.iter()
.filter(|msg| msg.tags.iter().any(|t| t == "unread"))
.map(|msg| &msg.id)
.collect();
if unread_messages.is_empty() {
// All messages are read, so mark them all unread
orders.send_msg(Msg::SetUnread(thread_id, true));
} else {
// Do nothing if there are some messages unread
}
} else {
// News post, not email, just mark unread
orders.send_msg(Msg::SetUnread(thread_id, true));
};
} else {
// This shouldn't happen
warn!("no current thread_id");
}
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,
@ -769,9 +565,6 @@ 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)]
@ -808,16 +601,6 @@ 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,
@ -831,29 +614,26 @@ 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,
// TODO: add GoToUrl // Window has changed size
OnUrlChanged(subs::UrlChanged), OnResize,
// 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),
SetUnread(String, bool), SetUnread(String, bool),
AddTag(String, String), AddTag(String, String),
RemoveTag(String, String), RemoveTag(String, String),
Snooze(String, DateTime<Utc>),
FrontPageRequest { FrontPageRequest {
query: String, query: String,
@ -871,14 +651,10 @@ 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>,
),
#[allow(dead_code)]
SelectionSetNone, SelectionSetNone,
#[allow(dead_code)]
SelectionSetAll, SelectionSetAll,
SelectionAddTag(String), SelectionAddTag(String),
#[allow(dead_code)] #[allow(dead_code)]
@ -894,17 +670,7 @@ 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),
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,213 +0,0 @@
use std::{collections::VecDeque, rc::Rc};
use letterbox_shared::WebsocketMessage;
use log::{debug, error};
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 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;
debug!("WebSocket connection is open now");
}
Msg::TextMessageReceived(msg) => {
model.updates.push_back(msg);
}
Msg::WebSocketClosed(close_event) => {
debug!(
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 => {
debug!("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) => {
debug!("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| {
debug!("{:#?}", client.status);
let msg = match *client.status.borrow() {
ConnectionStatus::Connecting => {
debug!("Connecting...");
None
}
ConnectionStatus::Connected => Some(Msg::WebSocketOpened),
ConnectionStatus::Error => Some(Msg::WebSocketFailed),
ConnectionStatus::Disconnected => {
debug!("Disconnected");
None
}
};
send(msg);
})));
let send = msg_sender.clone();
client.set_on_close(Some(Box::new(move |ev| {
debug!("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:?}"),
}
}

View File

@ -1,11 +0,0 @@
.mail-thread .content.from-noreply-news-bloomberg-com a {
background-color: initial !important;
}
.mail-thread .content.from-noreply-news-bloomberg-com h2 {
margin: 0 !important;
padding: 0 !important;
}
.mail-thread .content.from-dmarcreport-microsoft-com div {
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important;
}

View File

@ -2,23 +2,23 @@ html {
background-color: black; background-color: black;
} }
.mail-thread .content a, .mail-thread a,
.news-post a { .news-post a {
color: var(--color-link) !important; color: var(--color-link) !important;
text-decoration: underline; text-decoration: underline;
} }
.mail-thread .content br, .mail-thread br,
.news-post br { .news-post br {
display: block; display: block;
margin-top: 1em; margin-top: 1em;
content: " "; content: " ";
} }
.mail-thread .content h1, .mail-thread h1,
.mail-thread .content h2, .mail-thread h2,
.mail-thread .content h3, .mail-thread h3,
.mail-thread .content h4, .mail-thread h4,
.news-post h1, .news-post h1,
.news-post h2, .news-post h2,
.news-post h3, .news-post h3,
@ -27,33 +27,27 @@ html {
margin-bottom: 1em !important; margin-bottom: 1em !important;
} }
.mail-thread .content p, .mail-thread p,
.news-post p { .news-post p {
margin-bottom: 1em; margin-bottom: 1em;
} }
.mail-thread .content pre, .mail-thread pre,
.news-post pre { .mail-thread code,
font-family: monospace; .news-post pre,
background-color: #eee !important;
padding: 0.5em;
white-space: break-spaces;
}
.mail-thread .content code,
.news-post code { .news-post code {
font-family: monospace; font-family: monospace;
white-space: break-spaces;
background-color: #eee !important; background-color: #eee !important;
padding: 0.5em !important;
} }
.mail-thread .content blockquote { .mail-thread blockquote {
padding-left: 1em; padding-left: 1em;
border-left: 2px solid #ddd; border-left: 2px solid #ddd;
} }
.mail-thread .content ol, .mail-thread ol,
.mail-thread .content ul { .mail-thread ul {
margin-left: 2em; margin-left: 2em;
} }
@ -67,11 +61,6 @@ html {
display: none !important; display: none !important;
} }
.news-post.site-seiya-me figure>pre,
.news-post.site-seiya-me figure>pre>code {
background-color: black !important;
}
.news-post.site-slashdot .story-byline { .news-post.site-slashdot .story-byline {
display: block !important; display: block !important;
height: initial !important; height: initial !important;