diff --git a/app/src/components/nodes/Mail.tsx b/app/src/components/nodes/Mail.tsx index 257eff6..73f8fd5 100644 --- a/app/src/components/nodes/Mail.tsx +++ b/app/src/components/nodes/Mail.tsx @@ -3,9 +3,18 @@ import styles from "./Mail.module.scss"; import { Formik } from "formik"; import SettingsIcon from "@mui/icons-material/Settings"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import ReactTimeAgo from "react-time-ago"; +import { parseISO } from "date-fns"; export default function Mail() { const [showSettings, setShowSettings] = useState(false); + const fetchedMail = useQuery({ + queryKey: ["mail"], + queryFn: fetchMail, + staleTime: 10000, + }); + + const { isSuccess, data } = fetchedMail; return (
@@ -25,7 +34,26 @@ export default function Mail() {
)} -
+
+ {isSuccess && ( + + )} +
); } @@ -132,3 +160,9 @@ async function fetchMailConfig() { const data = await resp.json(); return data.configs; } + +async function fetchMail() { + const resp = await fetch("http://localhost:5195/mail"); + const data = await resp.json(); + return data; +} diff --git a/crates/panorama-daemon/src/mail.rs b/crates/panorama-daemon/src/mail.rs index 51a8b92..0e7a3e4 100644 --- a/crates/panorama-daemon/src/mail.rs +++ b/crates/panorama-daemon/src/mail.rs @@ -1,11 +1,12 @@ -use std::time::Duration; +use std::{collections::HashMap, default, time::Duration}; -use axum::{extract::State, Json}; -use cozo::{DbInstance, ScriptMutability}; +use axum::{extract::State, routing::head, Json}; +use cozo::{DataValue, DbInstance, JsonData, ScriptMutability}; use futures::TryStreamExt; use miette::IntoDiagnostic; use serde_json::Value; use tokio::{net::TcpStream, time::sleep}; +use uuid::Uuid; use crate::{error::AppResult, AppState}; @@ -13,15 +14,58 @@ pub async fn get_mail_config( State(state): State, ) -> AppResult> { let configs = fetch_mail_configs(&state.db)?; + Ok(Json(json!({ "configs": configs }))) +} + +pub async fn get_mail(State(state): State) -> AppResult> { + let mailboxes = state.db.run_script(" + ?[node_id, account_node_id, mailbox_name] := *mailbox {node_id, account_node_id, mailbox_name} + ", Default::default(), ScriptMutability::Immutable)?; + + let mailboxes = mailboxes + .rows + .iter() + .map(|mb| { + json!({ + "node_id": mb[0].get_str().unwrap(), + "account_node_id": mb[1].get_str().unwrap(), + "mailbox_name": mb[2].get_str().unwrap(), + }) + }) + .collect::>(); + + let messages = state.db.run_script(" + ?[node_id, subject, body, internal_date] := *message {node_id, subject, body, internal_date} + :limit 10 + ", Default::default(), ScriptMutability::Immutable)?; + + let messages = messages + .rows + .iter() + .map(|m| { + json!({ + "node_id": m[0].get_str().unwrap(), + "subject": m[1].get_str().unwrap(), + "body": m[2].get_str(), + "internal_date": m[3].get_str().unwrap(), + }) + }) + .collect::>(); + Ok(Json(json!({ - "configs": configs, + "mailboxes": mailboxes, + "messages": messages, }))) } pub async fn mail_loop(db: DbInstance) { loop { match mail_loop_inner(&db).await { - Ok(_) => sleep(Duration::from_secs(30)).await, + Ok(_) => { + // For now, just sleep 30 seconds and then fetch again + // TODO: Run a bunch of connections at once and do IDLE over them (if possible) + sleep(Duration::from_secs(30)).await; + } Err(err) => { eprintln!("Fetch config error: {err:?}"); // Back off, retry @@ -36,9 +80,13 @@ pub async fn mail_loop(db: DbInstance) { async fn mail_loop_inner(db: &DbInstance) -> AppResult<()> { // Fetch the mail configs let configs = fetch_mail_configs(&db)?; + if configs.len() == 0 { + return Ok(()); + } // TODO: Do all configs instead of just the first let config = &configs[0]; + let stream = TcpStream::connect((config.imap_hostname.as_str(), config.imap_port)) .await @@ -63,11 +111,43 @@ async fn mail_loop_inner(db: &DbInstance) -> AppResult<()> { mailboxes.iter().map(|name| name.name()).collect::>(); println!("mailboxes: {mailbox_names:?}"); + // Get the mailbox with INBOX + let inbox_node_id = { + let result = db.run_script(" + ?[node_id] := + *mailbox{node_id, account_node_id, mailbox_name}, + account_node_id = $account_node_id, + mailbox_name = 'INBOX' + ", btmap! {"account_node_id".to_owned()=>DataValue::from(config.node_id.to_owned())}, ScriptMutability::Immutable)?; + + if result.rows.len() == 0 { + let new_node_id = Uuid::now_v7(); + let new_node_id = new_node_id.to_string(); + db.run_script(" + ?[node_id, account_node_id, mailbox_name] <- + [[$new_node_id, $account_node_id, 'INBOX']] + :put mailbox { node_id, account_node_id, mailbox_name } + ", + btmap! { + "new_node_id".to_owned() => DataValue::from(new_node_id.clone()), + "account_node_id".to_owned() => DataValue::from(config.node_id.to_owned()), + }, + ScriptMutability::Mutable)?; + new_node_id + } else { + result.rows[0][0].get_str().unwrap().to_owned() + } + }; + println!("INBOX: {:?}", inbox_node_id); + let inbox = session.select("INBOX").await.into_diagnostic()?; println!("last unseen: {:?}", inbox.unseen); let messages = session - .fetch("1", "RFC822") + .fetch( + "1:4", + "(FLAGS ENVELOPE BODY[HEADER] BODY[TEXT] INTERNALDATE)", + ) .await .into_diagnostic()? .try_collect::>() @@ -77,10 +157,57 @@ async fn mail_loop_inner(db: &DbInstance) -> AppResult<()> { "messages {:?}", messages .iter() - .map(|f| f.body().and_then(|t| String::from_utf8(t.to_vec()).ok())) + .map(|f| f.internal_date()) .collect::>() ); + let input_data = DataValue::List( + messages + .iter() + .map(|msg| { + let message_id = Uuid::now_v7(); + let headers = + String::from_utf8(msg.header().unwrap().to_vec()).unwrap(); + let headers = headers + .split("\r\n") + .filter_map(|s| { + let p = s.split(": ").collect::>(); + if p.len() < 2 { + None + } else { + Some((p[0], p[1])) + } + }) + .collect::>(); + DataValue::List(vec![ + DataValue::from(message_id.to_string()), + DataValue::from(config.node_id.clone()), + DataValue::from(inbox_node_id.clone()), + DataValue::from( + headers + .get("Subject") + .map(|s| (*s).to_owned()) + .unwrap_or("Subject".to_owned()), + ), + DataValue::Json(JsonData(serde_json::to_value(headers).unwrap())), + DataValue::Bytes(msg.text().unwrap().to_vec()), + DataValue::from(msg.internal_date().unwrap().to_rfc3339()), + ]) + }) + .collect(), + ); + + db.run_script( + " + ?[node_id, account_node_id, mailbox_node_id, subject, headers, body, internal_date] <- $input_data + :put message { node_id, account_node_id, mailbox_node_id, subject, headers, body, internal_date } + ", + btmap! { + "input_data".to_owned() => input_data, + }, + ScriptMutability::Mutable, + )?; + session.logout().await.into_diagnostic()?; Ok(()) diff --git a/crates/panorama-daemon/src/main.rs b/crates/panorama-daemon/src/main.rs index 838d232..25c61c6 100644 --- a/crates/panorama-daemon/src/main.rs +++ b/crates/panorama-daemon/src/main.rs @@ -32,7 +32,7 @@ use tower_http::cors::{self, CorsLayer}; use crate::{ export::export, journal::get_todays_journal_id, - mail::{get_mail_config, mail_loop}, + mail::{get_mail, get_mail_config, mail_loop}, migrations::run_migrations, node::{create_node, get_node, node_types, search_nodes, update_node}, }; @@ -78,6 +78,7 @@ async fn main() -> Result<()> { .route("/node/types", get(node_types)) .route("/journal/get_todays_journal_id", get(get_todays_journal_id)) .route("/mail/config", get(get_mail_config)) + .route("/mail", get(get_mail)) .layer(ServiceBuilder::new().layer(cors)) .with_state(state); diff --git a/crates/panorama-daemon/src/migrations.rs b/crates/panorama-daemon/src/migrations.rs index 742b8a4..f34b381 100644 --- a/crates/panorama-daemon/src/migrations.rs +++ b/crates/panorama-daemon/src/migrations.rs @@ -161,6 +161,27 @@ fn migration_01(db: &DbInstance) -> Result<()> { imap_password: String, } } + { + :create mailbox { + node_id: String + => + account_node_id: String, + mailbox_name: String, + } + } + { ::index create mailbox:by_account_id_and_name { account_node_id, mailbox_name } } + { + :create message { + node_id: String + => + account_node_id: String, + mailbox_node_id: String, + subject: String, + headers: Json?, + body: Bytes, + internal_date: String, + } + } # Calendar ", diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index af61f13..e0a8351 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -1,5 +1,6 @@ # Summary +- [Front](./front.md) - [Nodes](./nodes.md) - [Custom Apps](./custom_apps.md) - [Sync](./sync.md) \ No newline at end of file diff --git a/docs/src/front.md b/docs/src/front.md new file mode 100644 index 0000000..37441f4 --- /dev/null +++ b/docs/src/front.md @@ -0,0 +1,6 @@ +# Panorama + +Panorama is a personal information manager. It relies on [Cozo](https://cozodb.org) as its primary data backend. + +- [Repository](https://git.mzhang.io/michael/panorama) +- [Issues](https://git.mzhang.io/michael/panorama/issues)