adding mail events into sqlite

This commit is contained in:
Jack Chakany 2025-03-01 17:37:56 -05:00
parent 21301cc603
commit a2deb4145b
6 changed files with 378 additions and 108 deletions

72
Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "ab_glyph"
@ -188,9 +188,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.82"
version = "1.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519"
checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4"
[[package]]
name = "arbitrary"
@ -1660,12 +1660,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
[[package]]
name = "foreign-types"
version = "0.5.0"
@ -1941,7 +1935,7 @@ checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c"
dependencies = [
"bitflags 2.6.0",
"gpu-descriptor-types",
"hashbrown 0.14.3",
"hashbrown",
]
[[package]]
@ -1973,22 +1967,13 @@ dependencies = [
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.15.2",
"hashbrown",
]
[[package]]
@ -2067,17 +2052,20 @@ dependencies = [
name = "hoot-app"
version = "0.1.0"
dependencies = [
"anyhow",
"eframe",
"egui_extras",
"egui_tabs",
"ewebsock",
"image 0.25.1",
"include_dir",
"nostr",
"pollster",
"puffin",
"puffin_http",
"rand",
"rusqlite",
"rusqlite_migration",
"security-framework",
"serde",
"serde_json",
@ -2205,6 +2193,25 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44feda355f4159a7c757171a77de25daf6411e217b4cabd03bd6650690468126"
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "2.2.6"
@ -2212,7 +2219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
dependencies = [
"equivalent",
"hashbrown 0.14.3",
"hashbrown",
]
[[package]]
@ -2418,9 +2425,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.31.0"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"openssl-sys",
@ -3474,9 +3481,9 @@ checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f"
[[package]]
name = "rusqlite"
version = "0.33.0"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.6.0",
"chrono",
@ -3488,6 +3495,17 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rusqlite_migration"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923b42e802f7dc20a0a6b5e097ba7c83fe4289da07e49156fecf6af08aa9cd1c"
dependencies = [
"include_dir",
"log",
"rusqlite",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"

View file

@ -6,15 +6,16 @@ edition = "2021"
publish = false
[features]
profiling = ["dep:puffin", "dep:puffin_http", "eframe/puffin", "egui_extras/puffin"]
profiling = [
"dep:puffin",
"dep:puffin_http",
"eframe/puffin",
"egui_extras/puffin",
]
[dependencies]
eframe = { version = "0.27.2", features = ["default", "persistence"] }
egui_extras = { version = "0.27.2", features = [
"file",
"image",
"svg",
] }
egui_extras = { version = "0.27.2", features = ["file", "image", "svg"] }
egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "120971fc43db6ba0b6f194f4bd4a66f7e00a4e22" }
image = { version = "0.25.1", features = ["jpeg", "png"] }
tracing = "0.1.40"
@ -28,7 +29,14 @@ nostr = { version = "0.37.0", features = ["std", "nip59"] }
serde = "1.0.204"
serde_json = "1.0.121"
pollster = "0.4.0"
rusqlite = { version = "0.33.0", features = ["chrono", "serde_json", "bundled-sqlcipher-vendored-openssl"] }
rusqlite = { version = "0.32.1", features = [
"chrono",
"serde_json",
"bundled-sqlcipher-vendored-openssl",
] }
rusqlite_migration = { version = "1.3.1", features = ["from-directory"] }
anyhow = "1.0.96"
include_dir = "0.7.4"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3.0.0"

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL,
tags TEXT NOT NULL CHECK (json_valid (tags)),
content TEXT NOT NULL,
sig TEXT
);

151
src/db.rs Normal file
View file

@ -0,0 +1,151 @@
use std::path::PathBuf;
use std::sync::LazyLock;
use anyhow::Result;
use include_dir::{include_dir, Dir};
use nostr::{Event, Kind};
use rusqlite::Connection;
use rusqlite_migration::{Migrations, M};
use serde_json::json;
use crate::account_manager::AccountManager;
use crate::mail_event::MAIL_EVENT_KIND;
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
static MIGRATIONS: LazyLock<Migrations<'static>> =
LazyLock::new(|| Migrations::from_directory(&MIGRATIONS_DIR).unwrap());
pub struct Db {
connection: Connection,
}
impl Db {
pub fn new(path: PathBuf) -> Result<Self> {
let mut conn = Connection::open(path)?;
// Apply migrations
MIGRATIONS.to_latest(&mut conn)?;
Ok(Self { connection: conn })
}
/// Store a mail event in the database
///
/// This function first attempts to unwrap the gift wrap if necessary,
/// and then stores the event in the database.
pub fn store_mail_event(
&self,
event: &Event,
account_manager: &mut AccountManager,
) -> Result<()> {
// Try to unwrap the gift wrap if this event is a gift wrap
let store_unwrapped =
is_gift_wrap(event) && account_manager.unwrap_gift_wrap(event).is_ok();
// Determine what event to store
if store_unwrapped {
// Unwrap succeeded, store the unwrapped event
let unwrapped = account_manager.unwrap_gift_wrap(event).unwrap();
// Get event details from the unwrapped gift
let id = match unwrapped.rumor.id {
Some(id) => id.to_string(),
None => "unknown".to_string(),
};
let pubkey = unwrapped.rumor.pubkey.to_string();
let created_at = unwrapped.rumor.created_at.as_u64();
let kind = unwrapped.rumor.kind.as_u16() as u32;
let tags_json = json!(unwrapped.rumor.tags).to_string();
let content = unwrapped.rumor.content.clone();
let sig = unwrapped.sender.to_string(); // Use sender pubkey as signature reference
// Store the unwrapped event in the database
self.connection.execute(
"INSERT OR REPLACE INTO events (id, pubkey, created_at, kind, tags, content, sig)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
(id, pubkey, created_at, kind, tags_json, content, sig),
)?;
} else {
// Use original event
// Convert tags to JSON string for storage
let tags_json = json!(event.tags).to_string();
// Get event details
let id = event.id.to_string();
let pubkey = event.pubkey.to_string();
let created_at = event.created_at.as_u64();
let kind = event.kind.as_u16() as u32;
let sig = event.sig.to_string();
// Store the event in the database
self.connection.execute(
"INSERT OR REPLACE INTO events (id, pubkey, created_at, kind, tags, content, sig)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
(id, pubkey, created_at, kind, tags_json, &event.content, sig),
)?;
}
Ok(())
}
/// Check if the database contains an event with the given ID
pub fn has_event(&self, event_id: &str) -> Result<bool> {
let count: i64 = self.connection.query_row(
"SELECT COUNT(*) FROM events WHERE id = ?",
[event_id],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Get all event IDs for mail events
pub fn get_mail_event_ids(&self) -> Result<Vec<String>> {
let mut stmt = self
.connection
.prepare("SELECT id FROM events WHERE kind = ?")?;
let mail_kind = u32::from(MAIL_EVENT_KIND as u16);
let id_iter = stmt.query_map([mail_kind], |row| {
let id: String = row.get(0)?;
Ok(id)
})?;
let mut ids = Vec::new();
for id_result in id_iter {
match id_result {
Ok(id) => ids.push(id),
Err(e) => {
tracing::error!("Error loading mail event ID: {}", e);
}
}
}
Ok(ids)
}
/// Get the JSON representation of an event by its ID
pub fn get_event_json(&self, event_id: &str) -> Result<Option<String>> {
let result = self.connection.query_row(
"SELECT json_object('id', id, 'pubkey', pubkey, 'created_at', created_at,
'kind', kind, 'tags', json(tags), 'content', content,
'sig', sig)
FROM events WHERE id = ?",
[event_id],
|row| row.get::<_, String>(0),
);
match result {
Ok(json) => Ok(Some(json)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
}
/// Check if an event is a gift wrap
fn is_gift_wrap(event: &Event) -> bool {
event.kind == Kind::GiftWrap
}

View file

@ -1,8 +1,8 @@
use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, TagKind, TagStandard};
use std::collections::HashMap;
use pollster::FutureExt as _;
use std::collections::HashMap;
pub const MAIL_EVENT_KIND: u16 = 1059;
pub const MAIL_EVENT_KIND: u16 = 2024;
pub struct MailMessage {
pub to: Vec<PublicKey>,
@ -23,19 +23,25 @@ impl MailMessage {
}
for pubkey in &self.cc {
tags.push(Tag::custom(TagKind::p(), vec![pubkey.to_hex().as_str(), "cc"]));
tags.push(Tag::custom(
TagKind::p(),
vec![pubkey.to_hex().as_str(), "cc"],
));
pubkeys_to_send_to.push(*pubkey);
}
tags.push(Tag::from_standardized(TagStandard::Subject(self.subject.clone())));
tags.push(Tag::from_standardized(TagStandard::Subject(
self.subject.clone(),
)));
let base_event = EventBuilder::new(Kind::Custom(MAIL_EVENT_KIND), &self.content)
.tags(tags);
let base_event = EventBuilder::new(Kind::Custom(MAIL_EVENT_KIND), &self.content).tags(tags);
let mut event_list: HashMap<PublicKey, Event> = HashMap::new();
for pubkey in pubkeys_to_send_to {
let wrapped_event =
EventBuilder::gift_wrap(sending_keys, &pubkey, base_event.clone(), None).block_on().unwrap();
EventBuilder::gift_wrap(sending_keys, &pubkey, base_event.clone(), None)
.block_on()
.unwrap();
event_list.insert(pubkey, wrapped_event);
}

View file

@ -8,6 +8,7 @@ use std::collections::HashMap;
use tracing::{debug, error, info, Level};
mod account_manager;
mod db;
mod error;
mod keystorage;
mod mail_event;
@ -84,6 +85,7 @@ pub struct Hoot {
relays: relay::RelayPool,
events: Vec<nostr::Event>,
account_manager: account_manager::AccountManager,
db: db::Db,
}
#[derive(Debug, PartialEq)]
@ -113,7 +115,17 @@ fn update_app(app: &mut Hoot, ctx: &egui::Context) {
if app.account_manager.loaded_keys.len() > 0 {
let mut gw_sub = relay::Subscription::default();
let filter = nostr::Filter::new().kind(nostr::Kind::Custom(mail_event::MAIL_EVENT_KIND)).custom_tag(nostr::SingleLetterTag { character: nostr::Alphabet::P, uppercase: false }, app.account_manager.loaded_keys.clone().into_iter().map(|keys| keys.public_key()));
let filter = nostr::Filter::new().kind(nostr::Kind::GiftWrap).custom_tag(
nostr::SingleLetterTag {
character: nostr::Alphabet::P,
uppercase: false,
},
app.account_manager
.loaded_keys
.clone()
.into_iter()
.map(|keys| keys.public_key()),
);
gw_sub.filter(filter);
// TODO: fix error handling
@ -154,13 +166,30 @@ fn process_event(app: &mut Hoot, _sub_id: &str, event_json: &str) {
// Parse the event using the RelayMessage type which handles the ["EVENT", subscription_id, event_json] format
if let Ok(event) = serde_json::from_str::<nostr::Event>(event_json) {
// Verify the event signature
if event.verify().is_ok() {
debug!("Verified event: {:?}", event);
app.events.push(event);
} else {
error!("Event verification failed");
// Check if we already have this event
if let Ok(has_event) = app.db.has_event(&event.id.to_string()) {
if has_event {
debug!("Skipping already stored event: {}", event.id);
return;
}
}
// Verify the event signature
if event.verify().is_ok() {
debug!("Verified event: {:?}", event);
// Store the event in memory
app.events.push(event.clone());
// Store the event in the database
if let Err(e) = app.db.store_mail_event(&event, &mut app.account_manager) {
error!("Failed to store event in database: {}", e);
} else {
debug!("Successfully stored event with id {} in database", event.id);
}
} else {
error!("Event verification failed for event: {}", event.id);
}
} else {
error!("Failed to parse event JSON: {}", event_json);
}
@ -182,10 +211,13 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
ui.add_space(16.0);
// Compose button
if ui.add_sized(
[180.0, 36.0],
egui::Button::new("✉ Compose").fill(egui::Color32::from_rgb(149, 117, 205)),
).clicked() {
if ui
.add_sized(
[180.0, 36.0],
egui::Button::new("✉ Compose").fill(egui::Color32::from_rgb(149, 117, 205)),
)
.clicked()
{
let state = ui::compose_window::ComposeWindowState {
subject: String::new(),
to_field: String::new(),
@ -197,9 +229,9 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
.compose_window
.insert(egui::Id::new(rand::random::<u32>()), state);
}
ui.add_space(16.0);
// Navigation items
let nav_items = [
("📥 Inbox", Page::Inbox, app.events.len()),
@ -212,18 +244,35 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
for (label, page, count) in nav_items {
let is_selected = app.page == page;
let response = ui.selectable_label(is_selected, format!("{} {}", label, if count > 0 { count.to_string() } else { String::new() }));
let response = ui.selectable_label(
is_selected,
format!(
"{} {}",
label,
if count > 0 {
count.to_string()
} else {
String::new()
}
),
);
if response.clicked() {
app.page = page;
}
}
if ui.button("onboarding").clicked() {
app.page = Page::OnboardingNew;
}
// Add flexible space to push profile to bottom
ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
ui.add_space(8.0);
// Profile section
ui.horizontal(|ui| {
if ui.add_sized([32.0, 32.0], egui::Button::new("👤")).clicked() {
if ui
.add_sized([32.0, 32.0], egui::Button::new("👤"))
.clicked()
{
app.page = Page::Settings;
}
if let Some(key) = app.account_manager.loaded_keys.first() {
@ -245,19 +294,19 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
[search_width, 32.0],
egui::TextEdit::singleline(&mut String::new())
.hint_text("Search")
.margin(egui::vec2(8.0, 4.0))
.margin(egui::vec2(8.0, 4.0)),
);
});
ui.add_space(8.0);
// Email list using TableBuilder
TableBuilder::new(ui)
.column(Column::auto()) // Checkbox
.column(Column::auto()) // Star
.column(Column::remainder()) // Sender
.column(Column::remainder()) // Content
.column(Column::remainder()) // Time
.column(Column::auto()) // Checkbox
.column(Column::auto()) // Star
.column(Column::remainder()) // Sender
.column(Column::remainder()) // Content
.column(Column::remainder()) // Time
.striped(true)
.sense(Sense::click())
.auto_shrink(Vec2b { x: false, y: false })
@ -283,37 +332,34 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
let events = app.events.clone();
body.rows(row_height, events.len(), |mut row| {
let event = &events[row.index()];
// Try to unwrap the gift wrap
if let Ok(unwrapped) = app.account_manager.unwrap_gift_wrap(event) {
row.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
ui.label(unwrapped.sender.to_string());
});
row.col(|ui| {
// Try to get subject from tags
let subject = match &unwrapped.rumor.tags.find(nostr::TagKind::Subject) {
Some(s) => match s.content() {
Some(c) => format!("{}: {}", c.to_string(), unwrapped.rumor.content),
None => unwrapped.rumor.content.clone(),
},
None => unwrapped.rumor.content.clone(),
};
ui.label(subject);
});
row.col(|ui| {
ui.label("2 minutes ago");
});
if row.response().clicked() {
app.focused_post = event.id.to_string();
app.page = Page::Post;
}
row.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
ui.label(event.pubkey.to_string());
});
row.col(|ui| {
// Try to get subject from tags
let subject = match &event.tags.find(nostr::TagKind::Subject) {
Some(s) => match s.content() {
Some(c) => format!("{}: {}", c.to_string(), event.content),
None => event.content.clone(),
},
None => event.content.clone(),
};
ui.label(subject);
});
row.col(|ui| {
ui.label("2 minutes ago");
});
if row.response().clicked() {
app.focused_post = event.id.to_string();
app.page = Page::Post;
}
});
});
@ -322,15 +368,24 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
ui::settings::SettingsScreen::ui(app, ui);
}
Page::Post => {
if let Some(event) = app.events.iter().find(|e| e.id.to_string() == app.focused_post) {
if let Some(event) = app
.events
.iter()
.find(|e| e.id.to_string() == app.focused_post)
{
if let Ok(unwrapped) = app.account_manager.unwrap_gift_wrap(event) {
// Message header section
ui.add_space(8.0);
ui.heading(&unwrapped.rumor.tags.find(nostr::TagKind::Subject)
.and_then(|s| s.content())
.map(|c| c.to_string())
.unwrap_or_else(|| "No Subject".to_string()));
ui.heading(
&unwrapped
.rumor
.tags
.find(nostr::TagKind::Subject)
.and_then(|s| s.content())
.map(|c| c.to_string())
.unwrap_or_else(|| "No Subject".to_string()),
);
// Metadata grid
egui::Grid::new("email_metadata")
.num_columns(2)
@ -341,11 +396,16 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
ui.end_row();
ui.label("To");
ui.label(unwrapped.rumor.tags.iter()
.filter_map(|tag| tag.content())
.next()
.unwrap_or_else(|| "Unknown")
.to_string());
ui.label(
unwrapped
.rumor
.tags
.iter()
.filter_map(|tag| tag.content())
.next()
.unwrap_or_else(|| "Unknown")
.to_string(),
);
ui.end_row();
});
@ -387,8 +447,7 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
ui.label("Your draft messages will appear here");
}
_ => {
ui.heading("Coming Soon");
ui.label("This feature is under development");
ui::onboarding::OnboardingScreen::ui(app, ui);
}
}
});
@ -396,7 +455,25 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
impl Hoot {
fn new(cc: &eframe::CreationContext<'_>) -> Self {
let storage_dir = eframe::storage_dir("Hoot").unwrap();
// Create storage directory if it doesn't exist
let storage_dir = eframe::storage_dir("hoot").unwrap();
std::fs::create_dir_all(&storage_dir).unwrap();
// Create the database file path
let db_path = storage_dir.join("hoot.db");
// Initialize the database
let db = match db::Db::new(db_path) {
Ok(db) => {
info!("Database initialized successfully");
db
}
Err(e) => {
error!("Failed to initialize database: {}", e);
panic!("Database initialization failed: {}", e);
}
};
Self {
page: Page::Inbox,
focused_post: "".into(),
@ -405,6 +482,7 @@ impl Hoot {
relays: relay::RelayPool::new(),
events: Vec::new(),
account_manager: account_manager::AccountManager::new(),
db,
}
}
}