initial concept for mail
This commit is contained in:
parent
abeb41857f
commit
81dc41bf4c
6 changed files with 199 additions and 9 deletions
|
@ -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 (
|
||||
<div className={styles.container}>
|
||||
|
@ -25,7 +34,26 @@ export default function Mail() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<AppState>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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!({
|
||||
"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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
|
@ -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::<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()?;
|
||||
|
||||
Ok(())
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# Summary
|
||||
|
||||
- [Front](./front.md)
|
||||
- [Nodes](./nodes.md)
|
||||
- [Custom Apps](./custom_apps.md)
|
||||
- [Sync](./sync.md)
|
6
docs/src/front.md
Normal file
6
docs/src/front.md
Normal 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)
|
Loading…
Reference in a new issue