initial concept for mail

This commit is contained in:
Michael Zhang 2024-05-27 20:29:36 -05:00
parent abeb41857f
commit 81dc41bf4c
6 changed files with 199 additions and 9 deletions

View file

@ -3,9 +3,18 @@ import styles from "./Mail.module.scss";
import { Formik } from "formik"; import { Formik } from "formik";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import ReactTimeAgo from "react-time-ago";
import { parseISO } from "date-fns";
export default function Mail() { export default function Mail() {
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const fetchedMail = useQuery({
queryKey: ["mail"],
queryFn: fetchMail,
staleTime: 10000,
});
const { isSuccess, data } = fetchedMail;
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -25,7 +34,26 @@ export default function Mail() {
</div> </div>
)} )}
<div className={styles.mailList}></div> <div className={styles.mailList}>
{isSuccess && (
<ul>
{data.messages.map((message) => {
const date = parseISO(message.internal_date);
return (
<li key={message.node_id}>
<details>
<summary>
{message.subject} (<ReactTimeAgo date={date} />)
</summary>
<small>{message.body}</small>
</details>
</li>
);
})}
</ul>
)}
</div>
</div> </div>
); );
} }
@ -132,3 +160,9 @@ async function fetchMailConfig() {
const data = await resp.json(); const data = await resp.json();
return data.configs; return data.configs;
} }
async function fetchMail() {
const resp = await fetch("http://localhost:5195/mail");
const data = await resp.json();
return data;
}

View file

@ -1,11 +1,12 @@
use std::time::Duration; use std::{collections::HashMap, default, time::Duration};
use axum::{extract::State, Json}; use axum::{extract::State, routing::head, Json};
use cozo::{DbInstance, ScriptMutability}; use cozo::{DataValue, DbInstance, JsonData, ScriptMutability};
use futures::TryStreamExt; use futures::TryStreamExt;
use miette::IntoDiagnostic; use miette::IntoDiagnostic;
use serde_json::Value; use serde_json::Value;
use tokio::{net::TcpStream, time::sleep}; use tokio::{net::TcpStream, time::sleep};
use uuid::Uuid;
use crate::{error::AppResult, AppState}; use crate::{error::AppResult, AppState};
@ -13,15 +14,58 @@ pub async fn get_mail_config(
State(state): State<AppState>, State(state): State<AppState>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let configs = fetch_mail_configs(&state.db)?; let configs = fetch_mail_configs(&state.db)?;
Ok(Json(json!({ "configs": configs })))
}
pub async fn get_mail(State(state): State<AppState>) -> AppResult<Json<Value>> {
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::<Vec<_>>();
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::<Vec<_>>();
Ok(Json(json!({ Ok(Json(json!({
"configs": configs, "mailboxes": mailboxes,
"messages": messages,
}))) })))
} }
pub async fn mail_loop(db: DbInstance) { pub async fn mail_loop(db: DbInstance) {
loop { loop {
match mail_loop_inner(&db).await { 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) => { Err(err) => {
eprintln!("Fetch config error: {err:?}"); eprintln!("Fetch config error: {err:?}");
// Back off, retry // Back off, retry
@ -36,9 +80,13 @@ pub async fn mail_loop(db: DbInstance) {
async fn mail_loop_inner(db: &DbInstance) -> AppResult<()> { async fn mail_loop_inner(db: &DbInstance) -> AppResult<()> {
// Fetch the mail configs // Fetch the mail configs
let configs = fetch_mail_configs(&db)?; let configs = fetch_mail_configs(&db)?;
if configs.len() == 0 {
return Ok(());
}
// TODO: Do all configs instead of just the first // TODO: Do all configs instead of just the first
let config = &configs[0]; let config = &configs[0];
let stream = let stream =
TcpStream::connect((config.imap_hostname.as_str(), config.imap_port)) TcpStream::connect((config.imap_hostname.as_str(), config.imap_port))
.await .await
@ -63,11 +111,43 @@ async fn mail_loop_inner(db: &DbInstance) -> AppResult<()> {
mailboxes.iter().map(|name| name.name()).collect::<Vec<_>>(); mailboxes.iter().map(|name| name.name()).collect::<Vec<_>>();
println!("mailboxes: {mailbox_names:?}"); 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()?; let inbox = session.select("INBOX").await.into_diagnostic()?;
println!("last unseen: {:?}", inbox.unseen); println!("last unseen: {:?}", inbox.unseen);
let messages = session let messages = session
.fetch("1", "RFC822") .fetch(
"1:4",
"(FLAGS ENVELOPE BODY[HEADER] BODY[TEXT] INTERNALDATE)",
)
.await .await
.into_diagnostic()? .into_diagnostic()?
.try_collect::<Vec<_>>() .try_collect::<Vec<_>>()
@ -77,10 +157,57 @@ async fn mail_loop_inner(db: &DbInstance) -> AppResult<()> {
"messages {:?}", "messages {:?}",
messages messages
.iter() .iter()
.map(|f| f.body().and_then(|t| String::from_utf8(t.to_vec()).ok())) .map(|f| f.internal_date())
.collect::<Vec<_>>() .collect::<Vec<_>>()
); );
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::<Vec<_>>();
if p.len() < 2 {
None
} else {
Some((p[0], p[1]))
}
})
.collect::<HashMap<_, _>>();
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()?; session.logout().await.into_diagnostic()?;
Ok(()) Ok(())

View file

@ -32,7 +32,7 @@ use tower_http::cors::{self, CorsLayer};
use crate::{ use crate::{
export::export, export::export,
journal::get_todays_journal_id, journal::get_todays_journal_id,
mail::{get_mail_config, mail_loop}, mail::{get_mail, get_mail_config, mail_loop},
migrations::run_migrations, migrations::run_migrations,
node::{create_node, get_node, node_types, search_nodes, update_node}, 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("/node/types", get(node_types))
.route("/journal/get_todays_journal_id", get(get_todays_journal_id)) .route("/journal/get_todays_journal_id", get(get_todays_journal_id))
.route("/mail/config", get(get_mail_config)) .route("/mail/config", get(get_mail_config))
.route("/mail", get(get_mail))
.layer(ServiceBuilder::new().layer(cors)) .layer(ServiceBuilder::new().layer(cors))
.with_state(state); .with_state(state);

View file

@ -161,6 +161,27 @@ fn migration_01(db: &DbInstance) -> Result<()> {
imap_password: String, 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 # Calendar
", ",

View file

@ -1,5 +1,6 @@
# Summary # Summary
- [Front](./front.md)
- [Nodes](./nodes.md) - [Nodes](./nodes.md)
- [Custom Apps](./custom_apps.md) - [Custom Apps](./custom_apps.md)
- [Sync](./sync.md) - [Sync](./sync.md)

6
docs/src/front.md Normal file
View file

@ -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)