Documentation Index
Fetch the complete documentation index at: https://mintlify.com/chakanysystems/hoot/llms.txt
Use this file to discover all available pages before exploring further.
Hoot uses custom kind 2024 events to implement email-like messaging over Nostr, with NIP-59 gift wrapping for privacy.
MailMessage structure
From src/mail_event.rs:8:
pub const MAIL_EVENT_KIND: u16 = 2024;
pub struct MailMessage {
pub id: Option<EventId>,
pub created_at: Option<i64>,
pub author: Option<PublicKey>,
pub to: Vec<PublicKey>,
pub cc: Vec<PublicKey>,
pub bcc: Vec<PublicKey>,
pub parent_events: Option<Vec<EventId>>,
pub subject: String,
pub content: String,
pub sender_nip05: Option<String>,
}
Key features:
- Email-like addressing: Separate
to, cc, and bcc recipient lists
- Threading:
parent_events vector references previous messages in conversation
- Subject line: Dedicated subject field for email-style organization
- NIP-05 identity: Optional verified identity for sender
- Flexible metadata: Optional
id, created_at, and author for both creating and displaying messages
Event generation
The to_events() method converts a MailMessage into gift-wrapped Nostr events:
pub fn to_events(&mut self, sending_keys: &Keys) -> HashMap<PublicKey, Event> {
let mut pubkeys_to_send_to: Vec<PublicKey> = Vec::new();
let mut tags: Vec<Tag> = Vec::new();
for pubkey in &self.to {
tags.push(Tag::public_key(*pubkey));
pubkeys_to_send_to.push(*pubkey);
}
for pubkey in &self.cc {
tags.push(Tag::custom(
TagKind::p(),
vec![pubkey.to_hex().as_str(), "cc"],
));
pubkeys_to_send_to.push(*pubkey);
}
if let Some(parentEvents) = &self.parent_events {
for event in parentEvents {
tags.push(Tag::event(*event));
}
}
if let Some(nip05) = &self.sender_nip05 {
tags.push(Tag::custom(TagKind::custom("nip05"), vec![nip05.as_str()]));
}
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 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();
event_list.insert(pubkey, wrapped_event);
}
event_list
}
Tag structure
The base kind 2024 event uses standard Nostr tags:
// To recipients: standard p tag
["p", "<recipient_pubkey>"]
// CC recipients: p tag with "cc" marker
["p", "<recipient_pubkey>", "cc"]
// BCC recipients: Not included in base event tags
// (gift wrap recipient only)
// References to parent messages
["e", "<parent_event_id>"]
["e", "<another_parent_event_id>"]
Multiple parent events supported for complex threading scenarios.
Subject tag
// Subject line
["subject", "Re: Previous conversation"]
NIP-05 tag
// Sender's verified identity
["nip05", "alice@example.com"]
Gift wrap process
Each message creates one gift-wrapped event per recipient:
for pubkey in pubkeys_to_send_to {
let wrapped_event =
EventBuilder::gift_wrap(sending_keys, &pubkey, base_event.clone(), None)
.block_on()
.unwrap();
event_list.insert(pubkey, wrapped_event);
}
NIP-59 gift wrapping:
- Base event: Kind 2024 event with all tags and content
- Seal: Base event encrypted for recipient, signed by sender
- Gift wrap: Seal encrypted and wrapped by random ephemeral key
- One per recipient: Each recipient gets individually encrypted copy
The returned HashMap<PublicKey, Event> maps each recipient to their gift-wrapped event.
BCC handling
BCC (blind carbon copy) recipients:
- Not in tags: BCC pubkeys excluded from base event’s
p tags
- Still wrapped: BCC recipients still get gift-wrapped copies
- Privacy preserved: Other recipients cannot see BCC recipients
// BCC recipients added to send list but not to tags
for pubkey in &self.bcc {
pubkeys_to_send_to.push(*pubkey);
// No tag added!
}
Threading implementation
Threading uses event references:
if let Some(parentEvents) = &self.parent_events {
for event in parentEvents {
tags.push(Tag::event(*event));
}
}
Thread reconstruction:
- Messages reference parent event IDs in
e tags
- Database queries walk these references recursively
- UI displays messages in chronological conversation order
- Multiple parent references support branching conversations
From the database layer, threads are reconstructed using recursive CTEs that walk both parent and child references.
Event flow
Sending a message
- User composes message in
ComposeWindow
- UI creates
MailMessage struct with recipients and content
to_events() generates one gift-wrapped event per recipient
- Each wrapped event sent to all connected relays
- Relays distribute gift wraps to recipient’s relay lists
Receiving a message
- Relay sends gift wrap event (kind 1059) to client
AccountManager::unwrap_gift_wrap() decrypts the gift wrap
- Inner rumor (kind 2024 event) extracted
- Database stores rumor with mapping to original gift wrap
- UI displays message in appropriate mailbox
Async handling
Gift wrap operations are async but called from sync context:
let wrapped_event =
EventBuilder::gift_wrap(sending_keys, &pubkey, base_event.clone(), None)
.block_on() // pollster::FutureExt
.unwrap();
Uses pollster::block_on() to handle async Nostr crypto operations in synchronous event generation.
Privacy features
Kind 2024 + NIP-59 provides strong privacy:
- Encrypted content: Message body encrypted in seal
- Encrypted metadata: Recipients, subject encrypted in seal
- Sender privacy: Gift wrap uses random ephemeral key
- Timing obfuscation: Gift wrap timestamps randomized (TODO)
- Individual encryption: Each recipient gets unique encrypted copy
From the code comment at src/mail_event.rs:60:
// TODO: randomize gift wrap created_ats
Future enhancement will add timestamp randomization for additional privacy.
Kind 2024 vs standard Nostr
Kind 2024 differs from standard Nostr events:
- Not kind 1: Regular text notes visible to followers
- Not DMs: Old-style encrypted DMs (deprecated)
- Email-like: To/CC/BCC, subject lines, threading
- Always wrapped: Never sent unwrapped (unlike kind 1)
- Custom kind: Specific to Hoot and compatible clients
This enables true email functionality while maintaining Nostr’s decentralized architecture.
Integration with UI
From src/ui/compose_window.rs:223:
let mut msg = MailMessage {
id: None,
created_at: None,
author: None,
to: recipient_keys,
cc: vec![],
bcc: vec![],
parent_events: Some(state.parent_events.clone()),
subject: state.subject.clone(),
content: state.content.clone(),
sender_nip05: state.selected_nip05.clone(),
};
let events_to_send = msg.to_events(&state.selected_account.clone().unwrap());
for event in events_to_send {
match serde_json::to_string(&ClientMessage::Event { event: event.1 }) {
Ok(v) => match app.relays.send(ewebsock::WsMessage::Text(v)) {
Ok(r) => r,
Err(e) => error!("could not send event to relays: {}", e),
},
Err(e) => error!("could not serialize event: {}", e),
};
}
The compose window:
- Collects message data from user input
- Creates
MailMessage struct
- Generates wrapped events with
to_events()
- Sends each event to relay pool
- Relay pool broadcasts to all connected relays
The mail event system provides the foundation for Hoot’s email-like experience over Nostr’s decentralized infrastructure.