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 { 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;
|
||||||
|
}
|
||||||
|
|
|
@ -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(())
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
",
|
",
|
||||||
|
|
|
@ -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
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