From 43c0ed678a373e57940dc2c7a222291646de0079 Mon Sep 17 00:00:00 2001 From: Atif Yushri Date: Sat, 4 Apr 2026 18:08:48 +0100 Subject: [PATCH] refactor!: migrate from redb to json storage and replace inquire with simple prompts --- Cargo.lock | 215 ++------------------------ Cargo.toml | 6 +- README.md | 12 +- src/app.rs | 390 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 164 ++++++++++---------- src/utilities.rs | 110 ------------- 6 files changed, 488 insertions(+), 409 deletions(-) create mode 100644 src/app.rs delete mode 100644 src/utilities.rs diff --git a/Cargo.lock b/Cargo.lock index b9aa633..11831bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler" @@ -63,6 +63,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arboard" version = "3.4.1" @@ -251,31 +257,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossterm" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" -dependencies = [ - "bitflags 1.3.2", - "crossterm_winapi", - "libc", - "mio", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "dirs" version = "5.0.1" @@ -297,12 +278,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "dyn-clone" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" - [[package]] name = "errno" version = "0.3.9" @@ -374,24 +349,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gethostname" version = "0.4.3" @@ -442,42 +399,6 @@ dependencies = [ "tiff", ] -[[package]] -name = "inquire" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" -dependencies = [ - "bitflags 2.6.0", - "crossterm", - "dyn-clone", - "fuzzy-matcher", - "fxhash", - "newline-converter", - "once_cell", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -559,27 +480,6 @@ dependencies = [ "adler2", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - -[[package]] -name = "newline-converter" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -694,17 +594,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -[[package]] -name = "open" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a877bf6abd716642a53ef1b89fb498923a4afca5c754f9050b4d081c05c4b3" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -734,12 +623,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "pathdiff" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" - [[package]] name = "percent-encoding" version = "2.3.1" @@ -818,9 +701,9 @@ dependencies = [ [[package]] name = "redb" -version = "2.1.3" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4760ad04a88ef77075ba86ba9ea79b919e6bab29c1764c5747237cd6eaedcaa" +checksum = "8eca1e9d98d5a7e9002d0013e18d5a9b000aee942eb134883a82f06ebffb6c01" dependencies = [ "libc", ] @@ -955,36 +838,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" -dependencies = [ - "libc", -] - [[package]] name = "simd-adler32" version = "0.3.7" @@ -1046,16 +899,6 @@ dependencies = [ "syn", ] -[[package]] -name = "thread_local" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" -dependencies = [ - "cfg-if", - "once_cell", -] - [[package]] name = "tiff" version = "0.9.1" @@ -1084,15 +927,15 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tmail" -version = "0.1.2" +version = "0.1.3" dependencies = [ + "anyhow", "arboard", "clap", "dirs", - "inquire", - "open", "rand", "redb", + "serde", "serde_json", "ureq", ] @@ -1118,18 +961,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-width" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" - [[package]] name = "untrusted" version = "0.9.0" @@ -1192,28 +1023,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 18d54e1..27b3b97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tmail" -version = "0.1.2" +version = "0.1.3" edition = "2021" authors = ["Atif Yushri "] description = "A temporary email address" @@ -10,12 +10,12 @@ keywords = ["email", "temporary", "tmail", "tempmail"] [dependencies] +anyhow = "1.0.100" arboard = "3.4.1" clap = { version = "4.5.17", features = ["derive"] } dirs = "5.0.1" -inquire = "0.7.5" -open = "5.3.0" rand = "0.8.5" redb = "2.1.3" +serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" ureq = { version = "2.10.1", features = ["json"] } diff --git a/README.md b/README.md index 9d9f256..8e8cd07 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # TMail -A temporary email address (although, the address is peristent). +A temporary email address (although, the address is persistent). ![TMail preview](https://github.com/user-attachments/assets/61d9e1e0-a9cd-4c71-80fd-eb758f838c65) @@ -31,13 +31,13 @@ tmail delete ``` ### Fetch -Retrieve messages from inbox and tries to open selected message in browser, +Retrieve messages from inbox and open the selected message in a browser. ``` tmail fetch -? Select a message -> Email Subject #1 - noreply@example.com -> Email Subject #2 - fake@email.com -[↑↓ to move, enter to select, type to filter] +Inbox: +1. Email Subject #1 - noreply@example.com +2. Email Subject #2 - fake@email.com +Select a message [1]: ``` ## Credits diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..bece35b --- /dev/null +++ b/src/app.rs @@ -0,0 +1,390 @@ +use anyhow::{bail, Context, Result}; +use rand::{ + distributions::{Alphanumeric, DistString}, + thread_rng, +}; +use redb::{Database, TableDefinition}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + fmt, fs, + path::{Path, PathBuf}, + process::Command, +}; + +const API_URL: &str = "https://api.mail.tm"; +const LEGACY_ACCOUNT: TableDefinition<&str, &str> = TableDefinition::new("account"); + +pub(crate) struct App { + paths: Paths, +} + +impl App { + pub(crate) fn load() -> Result { + let paths = Paths::new()?; + migrate_legacy_account(&paths)?; + + Ok(Self { paths }) + } + + pub(crate) fn create_account(&self) -> Result { + if self.read_account_if_exists()?.is_some() { + bail!("account already exists"); + } + + let domains: Collection = get_json(&format!("{API_URL}/domains"), None)?; + let Some(domain) = domains.items.first() else { + bail!("mail.tm did not return any domains"); + }; + + let address = EmailAddress::random(&domain.domain); + let password = Password::generate(); + let credentials = Credentials { + address: &address, + password: &password, + }; + + let created: CreatedAccount = post_json(&format!("{API_URL}/accounts"), &credentials)?; + let token: TokenResponse = post_json(&format!("{API_URL}/token"), &credentials)?; + + self.write_account(&Account { + address: address.clone(), + password, + id: created.id, + token: token.token, + })?; + + Ok(address) + } + + pub(crate) fn address(&self) -> Result { + Ok(self.read_account()?.address) + } + + pub(crate) fn delete_account(&self) -> Result<()> { + let account = self.read_account()?; + ureq::delete(&format!("{API_URL}/accounts/{}", account.id)) + .set("Authorization", &account.token.bearer()) + .call() + .context("failed to delete account")?; + + self.delete_account_file()?; + Ok(()) + } + + pub(crate) fn list_messages(&self) -> Result> { + let account = self.read_account()?; + let messages: Collection = + get_json(&format!("{API_URL}/messages"), Some(&account.token))?; + + Ok(messages.items) + } + + pub(crate) fn open_message(&self, message: &MessageSummary) -> Result<()> { + let html = self.fetch_message_html(&message.id)?; + fs::write(&self.paths.message_file, html) + .with_context(|| format!("failed to write {}", self.paths.message_file.display()))?; + open_in_browser(&self.paths.message_file) + } + + fn fetch_message_html(&self, message_id: &MessageId) -> Result { + let account = self.read_account()?; + let message: MessageBody = get_json( + &format!("{API_URL}/messages/{message_id}"), + Some(&account.token), + )?; + + message + .html + .into_iter() + .next() + .context("message did not include HTML content") + } + + fn read_account(&self) -> Result { + self.read_account_if_exists()? + .context("account has not been generated") + } + + fn read_account_if_exists(&self) -> Result> { + if !self.paths.account_file.exists() { + return Ok(None); + } + + Ok(Some(read_json(&self.paths.account_file)?)) + } + + fn write_account(&self, account: &Account) -> Result<()> { + write_json(&self.paths.account_file, account) + } + + fn delete_account_file(&self) -> Result<()> { + if self.paths.account_file.exists() { + fs::remove_file(&self.paths.account_file).with_context(|| { + format!("failed to remove {}", self.paths.account_file.display()) + })?; + } + + Ok(()) + } +} + +#[derive(Debug)] +struct Paths { + account_file: PathBuf, + legacy_account_file: PathBuf, + message_file: PathBuf, +} + +impl Paths { + fn new() -> Result { + let dir = program_dir()?; + fs::create_dir_all(&dir)?; + + Ok(Self { + account_file: dir.join("account.json"), + legacy_account_file: dir.join("accounts.redb"), + message_file: dir.join("message.html"), + }) + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct Account { + address: EmailAddress, + password: Password, + id: AccountId, + token: AuthToken, +} + +#[derive(Debug, Deserialize)] +struct Collection { + #[serde(rename = "hydra:member")] + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct Domain { + domain: String, +} + +#[derive(Debug, Deserialize)] +struct CreatedAccount { + id: AccountId, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + token: AuthToken, +} + +#[derive(Debug, Serialize)] +struct Credentials<'a> { + address: &'a EmailAddress, + password: &'a Password, +} + +#[derive(Debug, Default, Deserialize)] +struct MessageSender { + #[serde(default)] + address: EmailAddress, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct MessageSummary { + id: MessageId, + #[serde(default)] + subject: MessageSubject, + #[serde(default)] + from: MessageSender, +} + +#[derive(Debug, Deserialize)] +struct MessageBody { + #[serde(default)] + html: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(transparent)] +pub(crate) struct EmailAddress(String); + +impl EmailAddress { + fn random(domain: &str) -> Self { + Self(format!("{}@{}", random_string(8), domain).to_lowercase()) + } +} + +impl fmt::Display for EmailAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(transparent)] +struct Password(String); + +impl Password { + fn generate() -> Self { + Self(random_string(16)) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(transparent)] +struct AccountId(String); + +impl fmt::Display for AccountId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(transparent)] +struct AuthToken(String); + +impl AuthToken { + fn bearer(&self) -> String { + format!("Bearer {}", self.0) + } +} + +#[derive(Debug, Deserialize)] +#[serde(transparent)] +struct MessageId(String); + +impl fmt::Display for MessageId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(transparent)] +struct MessageSubject(String); + +impl fmt::Display for MessageSubject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let subject = match self.0.trim() { + "" => "(no subject)", + subject => subject, + }; + + f.write_str(subject) + } +} + +impl fmt::Display for MessageSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} - {}", self.subject, self.from.address) + } +} + +fn get_json(url: &str, token: Option<&AuthToken>) -> Result { + let request = ureq::get(url); + let request = match token { + Some(token) => request.set("Authorization", &token.bearer()), + None => request, + }; + + request + .call() + .with_context(|| format!("request failed: {url}"))? + .into_json() + .with_context(|| format!("failed to decode response from {url}")) +} + +fn post_json(url: &str, body: &S) -> Result { + ureq::post(url) + .send_json(body) + .with_context(|| format!("request failed: {url}"))? + .into_json() + .with_context(|| format!("failed to decode response from {url}")) +} + +fn program_dir() -> Result { + let home_dir = dirs::home_dir().context("could not find home directory")?; + + Ok(home_dir.join(format!(".{}", env!("CARGO_PKG_NAME")))) +} + +fn random_string(length: usize) -> String { + Alphanumeric.sample_string(&mut thread_rng(), length) +} + +fn migrate_legacy_account(paths: &Paths) -> Result<()> { + if paths.account_file.exists() || !paths.legacy_account_file.exists() { + return Ok(()); + } + + let database = Database::create(&paths.legacy_account_file)?; + let read_transaction = database.begin_read()?; + let table = match read_transaction.open_table(LEGACY_ACCOUNT) { + Ok(table) => table, + Err(_) => return Ok(()), + }; + + let Some(address) = table.get("address")? else { + return Ok(()); + }; + let Some(password) = table.get("password")? else { + return Ok(()); + }; + let Some(id) = table.get("id")? else { + return Ok(()); + }; + let Some(token) = table.get("token")? else { + return Ok(()); + }; + + fs::write( + &paths.account_file, + serde_json::to_vec_pretty(&Account { + address: EmailAddress(address.value().to_owned()), + password: Password(password.value().to_owned()), + id: AccountId(id.value().to_owned()), + token: AuthToken(token.value().to_owned()), + })?, + )?; + + Ok(()) +} + +fn read_json(path: &Path) -> Result { + let contents = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + + serde_json::from_str(&contents).with_context(|| format!("failed to parse {}", path.display())) +} + +fn write_json(path: &Path, value: &T) -> Result<()> { + let bytes = serde_json::to_vec_pretty(value).context("failed to serialize data")?; + fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display())) +} + +pub(crate) fn open_in_browser(path: &Path) -> Result<()> { + #[cfg(target_os = "macos")] + let status = Command::new("open").arg(path).status(); + + #[cfg(target_os = "linux")] + let status = Command::new("xdg-open").arg(path).status(); + + #[cfg(target_os = "windows")] + let status = Command::new("cmd") + .args(["/C", "start", ""]) + .arg(path) + .status(); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + bail!("opening messages in the browser is not supported on this platform"); + + let status = + status.with_context(|| format!("failed to launch browser for {}", path.display()))?; + + if status.success() { + Ok(()) + } else { + bail!("failed to open message in browser") + } +} diff --git a/src/main.rs b/src/main.rs index d8699fd..aad66aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,20 @@ -// #[cfg(test)] -mod utilities; +mod app; +use anyhow::{anyhow, bail, Result}; +use app::{App, EmailAddress, MessageSummary}; use arboard::Clipboard; -use clap::Parser; -use inquire::Select; -use redb::Database; -use std::fs::{create_dir, File}; -use std::io::prelude::*; -use std::path::PathBuf; -use std::{collections::HashMap, error::Error}; +use clap::{Parser, Subcommand}; +use std::io::{self, Write}; #[derive(Parser)] -enum TMail { +#[command(name = env!("CARGO_PKG_NAME"), version, about = "A temporary email address")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Clone, Copy, Subcommand)] +enum Command { /// Generate a new account Generate, /// Delete account @@ -22,93 +25,80 @@ enum TMail { Me, } -fn program_path() -> PathBuf { - let mut path = dirs::home_dir().unwrap(); - path.push(format!(".{}", env!("CARGO_PKG_NAME"))); - path +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + std::process::exit(1); + } } -fn db_path() -> PathBuf { - let mut path = program_path(); - path.push("accounts.redb"); - path -} - -fn index_path() -> PathBuf { - let mut path = program_path(); - path.push("index.html"); - path -} +fn run() -> Result<()> { + let app = App::load()?; -fn main() -> Result<(), Box> { - if create_dir(program_path()).is_ok() {} - let mut clipboard = Clipboard::new()?; - let client = TMail::parse(); - - match client { - TMail::Generate => { - let a = utilities::create_account()?; - println!("> {a} copied to clipboard!"); - clipboard.set_text(a)?; + match Cli::parse().command { + Command::Generate => print_address(&app.create_account()?), + Command::Delete => { + app.delete_account()?; + println!("> Account deleted"); } - TMail::Delete => match utilities::delete_account()? { - true => println!("> Account deleted"), - false => println!("Unable to delete account"), - }, - TMail::Me => { - let a = utilities::get_details()?; - println!("> {a} copied to clipboard!"); - clipboard.set_text(a)?; + Command::Me => print_address(&app.address()?), + Command::Fetch => { + let messages = app.list_messages()?; + let message = select_message(&messages)?; + app.open_message(message)?; } - TMail::Fetch => { - let m = utilities::retrieve_messages()?; - if m.is_empty() { - return Err(Box::from("inbox is empty")); - } + }; + + Ok(()) +} - let mut kv = HashMap::new(); - for e in m { - kv.insert( - e["subject"].as_str().unwrap().to_owned() - + " - " - + e["from"]["address"].as_str().unwrap(), - e["id"].as_str().unwrap().to_owned(), - ); - } +fn print_address(address: &EmailAddress) { + match Clipboard::new().and_then(|mut clipboard| clipboard.set_text(address.to_string())) { + Ok(()) => println!("> {address} copied to clipboard!"), + Err(_) => println!("> {address}"), + } +} - let c = Select::new( - "Select a message", - kv.clone().into_iter().map(|x| format!("{}", x.0)).collect(), - ) - .prompt(); +fn select_message(messages: &[MessageSummary]) -> Result<&MessageSummary> { + match messages { + [] => bail!("inbox is empty"), + [message] => Ok(message), + _ => { + print_inbox(messages); + let choice = prompt_choice(messages.len())?; + Ok(&messages[choice - 1]) + } + } +} - let database = Database::create(db_path())?; - let read_transaction = database.begin_read()?; - let table = read_transaction.open_table(utilities::ACCOUNT)?; +fn print_inbox(messages: &[MessageSummary]) { + println!("Inbox:"); + for (index, message) in messages.iter().enumerate() { + println!("{}. {message}", index + 1); + } +} - let res = ureq::get(&format!( - "https://api.mail.tm/messages/{}", - kv.get(&c?).unwrap() - )) - .set( - "Authorization", - &format!("Bearer {}", table.get("token")?.unwrap().value()), - ) - .call()? - .into_string()?; +fn prompt_choice(max: usize) -> Result { + let input = prompt("Select a message [1]: ")?; + if input.is_empty() { + return Ok(1); + } - let values = serde_json::from_str::(&res)?; - let html = values["html"].as_array().unwrap(); - if html.len() == 0 { - return Err(Box::from("No HTML content")); - } + let choice = input + .parse::() + .map_err(|_| anyhow!("selection must be a number"))?; + if (1..=max).contains(&choice) { + Ok(choice) + } else { + bail!("selection out of range") + } +} - let mut file = File::create(index_path())?; - file.write_all(b"")?; - file.write_all(&html[0].as_str().unwrap().as_bytes())?; - open::that(index_path())?; - } - }; +fn prompt(label: &str) -> Result { + print!("{label}"); + io::stdout().flush()?; - Ok(()) + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim().to_owned()) } diff --git a/src/utilities.rs b/src/utilities.rs deleted file mode 100644 index 5235e60..0000000 --- a/src/utilities.rs +++ /dev/null @@ -1,110 +0,0 @@ -use crate::db_path; -use rand::{ - distributions::{Alphanumeric, DistString}, - thread_rng, -}; -use redb::{Database, TableDefinition}; -use std::error::Error; -use ureq::json; - -pub(crate) const ACCOUNT: TableDefinition<&str, &str> = TableDefinition::new("account"); - -pub(crate) fn create_account() -> Result> { - let database = Database::create(db_path())?; - let read_transaction = database.begin_read()?; - if let Ok(table) = read_transaction.open_table(ACCOUNT) { - if table.get("address").is_ok() { - return Err(Box::from("account already exists")); - } - } - - // calling for domains - let res = ureq::get("https://api.mail.tm/domains") - .call()? - .into_string()?; - let values: serde_json::Value = serde_json::from_str(&res)?; - - let domain = values["hydra:member"][0]["domain"].as_str().unwrap(); - let address = (Alphanumeric.sample_string(&mut thread_rng(), 8) + "@" + domain).to_lowercase(); - let password = Alphanumeric.sample_string(&mut thread_rng(), 16); - - // creating account - let res = ureq::post("https://api.mail.tm/accounts") - .send_json(json!({"address": address, "password": password}))? - .into_string()?; - let values: serde_json::Value = serde_json::from_str(&res)?; - let id = values["id"].as_str().unwrap(); - - // retrieving token - let res = ureq::post("https://api.mail.tm/token") - .send_json(json!({"address": address, "password": password}))? - .into_string()?; - let values: serde_json::Value = serde_json::from_str(&res)?; - - let write_transaction = database.begin_write()?; - { - let mut table = write_transaction.open_table(ACCOUNT)?; - table.insert("address", address.as_str())?; - table.insert("password", password.as_str())?; - table.insert("id", id)?; - table.insert("token", values["token"].as_str().unwrap())?; - } - write_transaction.commit()?; - - Ok(address) -} - -pub(crate) fn get_details() -> Result> { - let database = Database::create(db_path())?; - let read_transaction = database.begin_read()?; - let table = read_transaction.open_table(ACCOUNT)?; - let Some(address) = table.get("address")? else { - return Err(Box::from("address does not exist")); - }; - - Ok(address.value().to_owned()) -} - -pub(crate) fn delete_account() -> Result> { - let database = Database::create(db_path())?; - let read_transaction = database.begin_read()?; - let table = read_transaction.open_table(ACCOUNT)?; - - let Some(id) = table.get("id")? else { - return Err(Box::from("id does not exist")); - }; - let Some(token) = table.get("token")? else { - return Err(Box::from("token does not exist")); - }; - - let write_transaction = database.begin_write()?; - write_transaction.delete_table(ACCOUNT)?; - write_transaction.commit()?; - - Ok(204 - == ureq::delete(&format!("https://api.mail.tm/accounts/{}", id.value())) - .set("Authorization", &format!("Bearer {}", token.value())) - .call()? - .status()) -} - -pub(crate) fn retrieve_messages() -> Result, Box> { - let database = Database::create(db_path())?; - let read_transaction = database.begin_read()?; - let table = match read_transaction.open_table(ACCOUNT) { - Ok(t) => t, - Err(_) => return Err(Box::from("account has not been generated")), - }; - - let res = ureq::get("https://api.mail.tm/messages") - .set( - "Authorization", - &format!("Bearer {}", table.get("token")?.unwrap().value()), - ) - .call()? - .into_string()?; - let values: serde_json::Value = serde_json::from_str(&res)?; - let emails = values["hydra:member"].as_array().unwrap().to_owned(); - - Ok(emails) -}