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 && (
+
+ {data.messages.map((message) => {
+ const date = parseISO(message.internal_date);
+ return (
+ -
+
+
+ {message.subject} ()
+
+
+ {message.body}
+
+
+ );
+ })}
+
+ )}
+
);
}
@@ -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)