feat: build carrier, a minimalist Iced smolmail client on fumi-core
This commit is contained in:
commit
e9e40bca36
12 changed files with 7698 additions and 0 deletions
672
src/ops.rs
Normal file
672
src/ops.rs
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
//! Blocking fumi-core operations and the local files around them. Everything
|
||||
//! here runs on a worker thread; the UI state only ever sees the plain
|
||||
//! values these functions return.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fumi_core::address::{Address, DEFAULT_PORT};
|
||||
use fumi_core::client::{FetchOptions, TrustChange};
|
||||
use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN};
|
||||
use fumi_core::error::Error;
|
||||
use fumi_core::store::Store;
|
||||
use fumi_core::{account, client, export, store};
|
||||
|
||||
/// One message in a folder list, already opened for its metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Row {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub subject: String,
|
||||
pub at: i64,
|
||||
pub unread: bool,
|
||||
}
|
||||
|
||||
/// A fully opened message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenedMsg {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub subject: String,
|
||||
pub time: i64,
|
||||
pub body: String,
|
||||
/// The sender's public key, base32 — the identity `accept` freezes.
|
||||
pub sender: String,
|
||||
/// The sender's self-certifying address, when the payload carried one.
|
||||
pub reply_to: Option<String>,
|
||||
pub kept: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Contact {
|
||||
pub address: String,
|
||||
pub key: String,
|
||||
pub verified: bool,
|
||||
/// Some(true) accepted, Some(false) blocked, None never decided.
|
||||
pub accepted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Who {
|
||||
pub address: String,
|
||||
pub key: String,
|
||||
pub fingerprint: String,
|
||||
pub rotations: u32,
|
||||
}
|
||||
|
||||
/// Everything the main pages display, refreshed in one blocking pass.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Snapshot {
|
||||
pub lists: Vec<Vec<Row>>,
|
||||
pub contacts: Vec<Contact>,
|
||||
pub pins: Vec<(String, [u8; KEY_LEN])>,
|
||||
pub who: Who,
|
||||
}
|
||||
|
||||
pub fn snapshot(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
read: &HashSet<String>,
|
||||
) -> Result<Snapshot, String> {
|
||||
Ok(Snapshot {
|
||||
lists: lists(store, master, read)?,
|
||||
contacts: contacts(store)?,
|
||||
pins: pins(store)?,
|
||||
who: whoami(store, master)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The three folders, in sidebar order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Folder {
|
||||
Inbox,
|
||||
Requests,
|
||||
Sent,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
pub const ALL: [Folder; 3] = [Folder::Inbox, Folder::Requests, Folder::Sent];
|
||||
|
||||
fn table(self) -> &'static str {
|
||||
match self {
|
||||
Folder::Inbox => "inbox",
|
||||
Folder::Requests => "requests",
|
||||
Folder::Sent => "sent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn explain(e: Error) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
pub fn new_master() -> [u8; KEY_LEN] {
|
||||
use rand_core::RngCore;
|
||||
let mut master = [0u8; KEY_LEN];
|
||||
rand_core::OsRng.fill_bytes(&mut master);
|
||||
master
|
||||
}
|
||||
|
||||
pub fn seed_text(master: &[u8; KEY_LEN]) -> String {
|
||||
b32(master)
|
||||
}
|
||||
|
||||
/// A seed as base32 or 64 hex characters, the two forms a paste can carry.
|
||||
pub fn parse_seed(text: &str) -> Result<[u8; KEY_LEN], String> {
|
||||
let text = text.trim();
|
||||
let bytes = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let mut raw = Vec::with_capacity(32);
|
||||
for pair in text.as_bytes().chunks(2) {
|
||||
let hi = (pair[0] as char).to_digit(16).ok_or("bad hex")?;
|
||||
let lo = (pair[1] as char).to_digit(16).ok_or("bad hex")?;
|
||||
raw.push((hi * 16 + lo) as u8);
|
||||
}
|
||||
raw
|
||||
} else {
|
||||
unb32(text).map_err(|e| explain(e))?
|
||||
};
|
||||
bytes
|
||||
.try_into()
|
||||
.map_err(|_| "a seed is 32 bytes".to_string())
|
||||
}
|
||||
|
||||
pub fn load_key(path: &Path) -> Result<[u8; KEY_LEN], String> {
|
||||
let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
raw.try_into()
|
||||
.map_err(|_| format!("{}: not a 32-byte master", path.display()))
|
||||
}
|
||||
|
||||
pub fn save_key(path: &Path, master: &[u8; KEY_LEN]) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
let err = {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
file.write_all(master).map_err(|e| e.to_string())
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let err = std::fs::write(path, master).map_err(|e| e.to_string());
|
||||
err
|
||||
}
|
||||
|
||||
pub fn account(store: &Store, master: &[u8; KEY_LEN]) -> Result<account::Account, String> {
|
||||
let index = store.rotations().map_err(explain)?;
|
||||
account::Account::new(*master, index).map_err(explain)
|
||||
}
|
||||
|
||||
pub fn parse_address(text: &str) -> Result<Address, String> {
|
||||
Address::parse(text).map_err(explain)
|
||||
}
|
||||
|
||||
/// A base32 public key, the form the operator publishes and pins are shown in.
|
||||
pub fn parse_key(text: &str) -> Result<[u8; KEY_LEN], String> {
|
||||
unb32(text.trim())
|
||||
.map_err(explain)?
|
||||
.try_into()
|
||||
.map_err(|_| "keys are 32 bytes of base32".to_string())
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
addr: &Address,
|
||||
invite: Option<&str>,
|
||||
timeout: u64,
|
||||
) -> Result<(), String> {
|
||||
let me = account(store, master)?;
|
||||
client::register(store, addr, &me, invite, timeout).map_err(explain)
|
||||
}
|
||||
|
||||
pub fn restore(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
addr: &Address,
|
||||
timeout: u64,
|
||||
) -> Result<u32, String> {
|
||||
client::restore(store, master, addr, timeout).map_err(explain)
|
||||
}
|
||||
|
||||
/// Connects without requiring a pin and reports the static key the server
|
||||
/// presented, so the user can decide to pin it (sec 4 trust on first use).
|
||||
/// None when a pin already covers this host.
|
||||
pub fn learn_key(
|
||||
store: &Store,
|
||||
addr: &Address,
|
||||
timeout: u64,
|
||||
) -> Result<Option<[u8; KEY_LEN]>, String> {
|
||||
if store
|
||||
.server_pin(&addr.host, addr.port)
|
||||
.map_err(explain)?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let mut session = client::connect(store, addr, false, timeout).map_err(explain)?;
|
||||
let offered = session.unpinned_static;
|
||||
session.close();
|
||||
Ok(offered)
|
||||
}
|
||||
|
||||
pub fn pin(store: &Store, addr: &Address, key: &[u8; KEY_LEN]) -> Result<(), String> {
|
||||
store
|
||||
.pin_server(&addr.host, addr.port, key)
|
||||
.map_err(explain)
|
||||
}
|
||||
|
||||
/// Splits a stored pin label (`host` or `host:port`) back into its parts.
|
||||
fn label_parts(label: &str) -> (String, u16) {
|
||||
match label.rsplit_once(':') {
|
||||
Some((host, port)) => (host.to_string(), port.parse().unwrap_or(DEFAULT_PORT)),
|
||||
None => (label.to_string(), DEFAULT_PORT),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unpin(store: &Store, label: &str) -> Result<(), String> {
|
||||
let (host, port) = label_parts(label);
|
||||
store.unpin_server(&host, port).map_err(explain)
|
||||
}
|
||||
|
||||
pub fn pins(store: &Store) -> Result<Vec<(String, [u8; KEY_LEN])>, String> {
|
||||
store.pins().map_err(explain)
|
||||
}
|
||||
|
||||
/// Fetches with the default cursor policy, returning a one-line summary.
|
||||
pub fn fetch(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
cancel: Arc<AtomicBool>,
|
||||
timeout: u64,
|
||||
) -> Result<String, String> {
|
||||
let me = account(store, master)?;
|
||||
let opts = FetchOptions {
|
||||
keep: false,
|
||||
reset: false,
|
||||
timeout,
|
||||
dial: None,
|
||||
cancel: Some(&cancel),
|
||||
};
|
||||
let fetched = client::fetch_with(store, &me, opts).map_err(explain)?;
|
||||
let mut note = format!("{} fetched, {} stored", fetched.total, fetched.stored);
|
||||
if !fetched.rejected.is_empty() {
|
||||
note.push_str(&format!(", {} rejected", fetched.rejected.len()));
|
||||
}
|
||||
if fetched.cancelled {
|
||||
note.push_str(" (cancelled)");
|
||||
}
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
pub fn send(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
to: &str,
|
||||
subject: Option<&str>,
|
||||
body: &str,
|
||||
reply_to: Option<&str>,
|
||||
timeout: u64,
|
||||
) -> Result<String, String> {
|
||||
let addr = parse_address(to)?;
|
||||
let me = account(store, master)?;
|
||||
let draft = client::SendDraft {
|
||||
address: &addr,
|
||||
text: body.to_string(),
|
||||
subject,
|
||||
reply_to,
|
||||
headers: &[],
|
||||
anonymous: false,
|
||||
no_pad: false,
|
||||
};
|
||||
let sent = client::send(store, &me, &draft, timeout).map_err(explain)?;
|
||||
let change = match sent.change {
|
||||
TrustChange::New { unverified: true } => {
|
||||
"; new contact learned from an unpinned server (unverified)"
|
||||
}
|
||||
TrustChange::New { unverified: false } => "; new contact learned (trust on first use)",
|
||||
TrustChange::Rotated => "; contact rotated to a new key (chain verified)",
|
||||
TrustChange::None => "",
|
||||
};
|
||||
let mut note = format!("sent to {} ({} bytes)", addr.short(), sent.bytes);
|
||||
if sent.token_used {
|
||||
note.push_str(", accept token attached");
|
||||
}
|
||||
note.push_str(change);
|
||||
if let Some(warning) = sent.warning {
|
||||
note.push_str(&format!("; {warning}"));
|
||||
}
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Removes one message locally, and on the server when `everywhere` is set.
|
||||
pub fn delete(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
id: &str,
|
||||
folder: Folder,
|
||||
everywhere: bool,
|
||||
timeout: u64,
|
||||
) -> Result<(), String> {
|
||||
let raw = hex32(id)?;
|
||||
store.delete_local(folder.table(), &raw).map_err(explain)?;
|
||||
if everywhere {
|
||||
let me = account(store, master)?;
|
||||
client::delete(store, &me, &[raw], None, timeout).map_err(explain)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Admits a correspondent to the main tier and pushes the changed token set.
|
||||
pub fn accept_contact(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
address: &str,
|
||||
identity: &[u8; KEY_LEN],
|
||||
timeout: u64,
|
||||
) -> Result<(), String> {
|
||||
store.accept(address, identity).map_err(explain)?;
|
||||
let me = account(store, master)?;
|
||||
client::push_tokens(store, &me, None, timeout).map_err(explain)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Blocks a correspondent and pushes the changed token set.
|
||||
pub fn block_contact(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
address: &str,
|
||||
timeout: u64,
|
||||
) -> Result<bool, String> {
|
||||
let blocked = store.block(address).map_err(explain)?;
|
||||
let me = account(store, master)?;
|
||||
client::push_tokens(store, &me, None, timeout).map_err(explain)?;
|
||||
Ok(blocked)
|
||||
}
|
||||
|
||||
/// Adds a contact from a self-certifying `smol://` address.
|
||||
pub fn import_contact(store: &Store, uri: &str) -> Result<(String, String), String> {
|
||||
let addr = parse_address(uri)?;
|
||||
let key = addr
|
||||
.identity
|
||||
.ok_or("import needs a self-certifying smol:// address carrying a key")?;
|
||||
store
|
||||
.save_contact(&addr.short(), &key, true)
|
||||
.map_err(explain)?;
|
||||
Ok((addr.short(), b32(&key)))
|
||||
}
|
||||
|
||||
pub fn contacts(store: &Store) -> Result<Vec<Contact>, String> {
|
||||
Ok(store
|
||||
.contact_rows()
|
||||
.map_err(explain)?
|
||||
.into_iter()
|
||||
.map(|(address, identity, verified, accepted)| Contact {
|
||||
address,
|
||||
key: b32(&identity),
|
||||
verified,
|
||||
accepted: accepted.map(|state| state == 1),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn rotate(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
timeout: u64,
|
||||
) -> Result<(u32, String), String> {
|
||||
let me = account(store, master)?;
|
||||
let rotated = client::rotate(store, &me, None, timeout).map_err(explain)?;
|
||||
Ok((store.rotations().map_err(explain)?, b32(&rotated.new_pk)))
|
||||
}
|
||||
|
||||
/// Writes the gsmol backup beside the store and returns it for the clipboard.
|
||||
pub fn export_backup(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
dir: &Path,
|
||||
) -> Result<(PathBuf, String), String> {
|
||||
let text = export::export(store, master).map_err(explain)?;
|
||||
let path = dir.join(format!("carrier-backup-{}.gsmol", store::now()));
|
||||
std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Ok((path, text))
|
||||
}
|
||||
|
||||
pub fn import_backup(store: &Store, master: &[u8; KEY_LEN], text: &str) -> Result<String, String> {
|
||||
let summary = export::import(store, master, text).map_err(explain)?;
|
||||
Ok(summary.to_string())
|
||||
}
|
||||
|
||||
fn hex32(text: &str) -> Result<[u8; KEY_LEN], String> {
|
||||
let raw = if text.len() == 64 && text.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
(0..32)
|
||||
.map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
return Err("message ids are 64 hex characters".to_string());
|
||||
};
|
||||
raw.try_into()
|
||||
.map_err(|_| "message ids are 32 bytes".to_string())
|
||||
}
|
||||
|
||||
/// Lists one folder as rows, marking unread against the local read set.
|
||||
fn rows(store: &Store, me: &account::Account, folder: Folder, read: &HashSet<String>) -> Vec<Row> {
|
||||
store
|
||||
.mail(folder.table())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|stored| {
|
||||
let id = b32_hex(&stored.id);
|
||||
let described = client::describe(store, me, &stored).ok()?;
|
||||
let from = match folder {
|
||||
Folder::Sent => stored.recipient.clone().unwrap_or_default(),
|
||||
_ => described.from,
|
||||
};
|
||||
let unread = !read.contains(&id);
|
||||
Some(Row {
|
||||
id,
|
||||
from,
|
||||
subject: described.subject,
|
||||
at: described.time,
|
||||
unread,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// All three folders at once, so the sidebar counts never go stale.
|
||||
pub fn lists(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
read: &HashSet<String>,
|
||||
) -> Result<Vec<Vec<Row>>, String> {
|
||||
let me = account(store, master)?;
|
||||
Ok(Folder::ALL
|
||||
.iter()
|
||||
.map(|folder| rows(store, &me, *folder, read))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
id: &str,
|
||||
folder: Folder,
|
||||
) -> Result<OpenedMsg, String> {
|
||||
let raw = hex32(id)?;
|
||||
let me = account(store, master)?;
|
||||
let stored = store
|
||||
.mail(folder.table())
|
||||
.map_err(explain)?
|
||||
.into_iter()
|
||||
.find(|m| m.id == raw)
|
||||
.ok_or_else(|| "message not found".to_string())?;
|
||||
let described = client::describe(store, &me, &stored).map_err(explain)?;
|
||||
Ok(OpenedMsg {
|
||||
id: id.to_string(),
|
||||
from: match folder {
|
||||
Folder::Sent => stored.recipient.clone().unwrap_or_default(),
|
||||
_ => described.from,
|
||||
},
|
||||
subject: described.subject,
|
||||
time: described.time,
|
||||
body: described.text,
|
||||
sender: b32(&described.sender),
|
||||
reply_to: described.fields.get("reply-to").cloned(),
|
||||
kept: stored.kept,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn whoami(store: &Store, master: &[u8; KEY_LEN]) -> Result<Who, String> {
|
||||
let me = account(store, master)?;
|
||||
let address = store
|
||||
.account()
|
||||
.map_err(explain)?
|
||||
.map(|addr| addr.short())
|
||||
.unwrap_or_default();
|
||||
Ok(Who {
|
||||
address,
|
||||
key: b32(&me.me().pk()),
|
||||
fingerprint: fingerprint(&me.me().pk()),
|
||||
rotations: me.index(),
|
||||
})
|
||||
}
|
||||
|
||||
fn b32_hex(raw: &[u8; KEY_LEN]) -> String {
|
||||
raw.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// UTC stamp for an epoch-second timestamp; mail is not minute-critical and
|
||||
/// the writer's zone is unknown, so UTC is the honest display.
|
||||
pub fn when(at: i64) -> String {
|
||||
let days = at.div_euclid(86400);
|
||||
let secs = at.rem_euclid(86400);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
|
||||
secs / 3600,
|
||||
(secs % 3600) / 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if month <= 2 { year + 1 } else { year }, month, day)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fumi_core::message::{build_frontmatter, message_id, seal};
|
||||
use fumi_core::store::now;
|
||||
|
||||
fn master() -> [u8; KEY_LEN] {
|
||||
let mut seed = [0u8; KEY_LEN];
|
||||
for (i, b) in seed.iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
seed
|
||||
}
|
||||
|
||||
fn setup() -> Store {
|
||||
Store::open_in_memory().expect("in-memory store")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_round_trips_through_base32_and_hex() {
|
||||
let m = master();
|
||||
assert_eq!(parse_seed(&seed_text(&m)).unwrap(), m);
|
||||
let hex: String = m.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(parse_seed(&hex).unwrap(), m);
|
||||
assert!(parse_seed("not a seed").is_err());
|
||||
assert!(parse_seed(&hex[..63]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_parsing_round_trips() {
|
||||
let m = master();
|
||||
assert_eq!(parse_key(&b32(&m)).unwrap(), m);
|
||||
assert!(parse_key("short").is_err());
|
||||
}
|
||||
|
||||
/// A sealed self-copy in the store surfaces as a row, opens with its
|
||||
/// frontmatter split, and flips to read against the marks set.
|
||||
#[test]
|
||||
fn list_and_open_round_trip() {
|
||||
let store = setup();
|
||||
let m = master();
|
||||
let me = account(&store, &m).unwrap();
|
||||
let addr = Address::parse("me@localhost").unwrap();
|
||||
store.set_account(&addr).unwrap();
|
||||
|
||||
let body = build_frontmatter(
|
||||
&[("Subject", "hello"), ("Reply-To", &addr.uri(&me.me().pk()))],
|
||||
"the body text",
|
||||
);
|
||||
let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap();
|
||||
let id = message_id(&envelope);
|
||||
store
|
||||
.store_inbox(&id, &envelope, now(), false, false)
|
||||
.unwrap();
|
||||
let id_hex: String = id.iter().map(|b| format!("{b:02x}")).collect();
|
||||
|
||||
let empty = HashSet::new();
|
||||
let folders = lists(&store, &m, &empty).unwrap();
|
||||
let inbox = &folders[0];
|
||||
assert_eq!(inbox.len(), 1);
|
||||
assert_eq!(inbox[0].subject, "hello");
|
||||
assert_eq!(inbox[0].from, "me@localhost");
|
||||
assert!(inbox[0].unread);
|
||||
|
||||
let opened = open(&store, &m, &id_hex, Folder::Inbox).unwrap();
|
||||
assert_eq!(opened.subject, "hello");
|
||||
assert_eq!(opened.body, "the body text");
|
||||
assert!(opened.reply_to.is_some());
|
||||
|
||||
let mut read = HashSet::new();
|
||||
read.insert(id_hex);
|
||||
let folders = lists(&store, &m, &read).unwrap();
|
||||
assert!(!folders[0][0].unread);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_contact_saves_verified() {
|
||||
let store = setup();
|
||||
let me = account(&store, &master()).unwrap();
|
||||
let addr = Address::parse("me@localhost").unwrap();
|
||||
let uri = addr.uri(&me.me().pk());
|
||||
import_contact(&store, &uri).unwrap();
|
||||
let rows = contacts(&store).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].address, "me@localhost");
|
||||
assert!(rows[0].verified);
|
||||
// A short form carries no key, so importing it is an error.
|
||||
assert!(import_contact(&store, "me@localhost").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_round_trips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = setup();
|
||||
let m = master();
|
||||
let me = account(&store, &m).unwrap();
|
||||
let addr = Address::parse("me@localhost").unwrap();
|
||||
store.set_account(&addr).unwrap();
|
||||
|
||||
let body = build_frontmatter(&[("Subject", "kept")], "sealed body");
|
||||
let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap();
|
||||
let id = message_id(&envelope);
|
||||
store
|
||||
.store_inbox(&id, &envelope, now(), false, false)
|
||||
.unwrap();
|
||||
|
||||
let (path, text) = export_backup(&store, &m, dir.path()).unwrap();
|
||||
assert!(path.exists());
|
||||
assert!(!text.is_empty());
|
||||
|
||||
let fresh = setup();
|
||||
let summary = import_backup(&fresh, &m, &text).unwrap();
|
||||
assert!(summary.contains("1 messages"));
|
||||
let empty = HashSet::new();
|
||||
let folders = lists(&fresh, &m, &empty).unwrap();
|
||||
assert_eq!(folders[0].len(), 1);
|
||||
assert_eq!(folders[0][0].subject, "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpin_splits_the_label() {
|
||||
let store = setup();
|
||||
let addr = Address::parse("me@localhost:1962").unwrap();
|
||||
let key = master();
|
||||
store.pin_server(&addr.host, addr.port, &key).unwrap();
|
||||
assert_eq!(pin(&store, &addr, &key), Ok(()));
|
||||
let all = pins(&store).unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].0, "localhost:1962");
|
||||
unpin(&store, "localhost:1962").unwrap();
|
||||
assert!(pins(&store).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamps_are_utc_civil_dates() {
|
||||
assert_eq!(when(0), "1970-01-01 00:00");
|
||||
assert_eq!(when(1_759_279_361), "2025-10-01 00:42");
|
||||
// Negative stays well-defined (pre-1970).
|
||||
assert_eq!(when(-1), "1969-12-31 23:59");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue