use chrono::NaiveDate;
use clap::{Parser, Subcommand};
use protonmail_client::{Email, Folder, ImapConfig, ProtonClient};
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(name = "proton-cli")]
#[command(about = "Read-only CLI for Proton Mail via Proton Bridge")]
struct Args {
#[command(subcommand)]
command: Command,
#[arg(long, global = true)]
json: bool,
}
#[derive(Subcommand)]
enum Command {
List {
#[arg(long, default_value = "INBOX")]
folder: String,
#[arg(long, default_value = "20")]
limit: usize,
#[arg(long)]
unseen: bool,
#[arg(long, value_parser = parse_date)]
since: Option<NaiveDate>,
#[arg(long, value_parser = parse_date)]
before: Option<NaiveDate>,
},
Show {
uid: u32,
#[arg(long, default_value = "INBOX")]
folder: String,
},
Folders,
Search {
query: String,
#[arg(long, default_value = "INBOX")]
folder: String,
#[arg(long, default_value = "50")]
limit: usize,
},
}
fn parse_date(s: &str) -> Result<NaiveDate, String> {
NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| format!("Invalid date '{s}': {e}"))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let args = Args::parse();
let config = ImapConfig::from_env()?;
let client = ProtonClient::new(config);
match &args.command {
Command::List {
folder,
limit,
unseen,
since,
before,
} => {
let folder = Folder::from(folder.as_str());
cmd_list(&client, &args, &folder, *limit, *unseen, *since, *before).await?;
}
Command::Show { uid, folder } => {
let folder = Folder::from(folder.as_str());
cmd_show(&client, &args, &folder, *uid).await?;
}
Command::Folders => {
cmd_folders(&client, &args).await?;
}
Command::Search {
query,
folder,
limit,
} => {
let folder = Folder::from(folder.as_str());
cmd_search(&client, &args, &folder, query, *limit).await?;
}
}
Ok(())
}
async fn cmd_list(
client: &ProtonClient,
args: &Args,
folder: &Folder,
limit: usize,
unseen: bool,
since: Option<NaiveDate>,
before: Option<NaiveDate>,
) -> anyhow::Result<()> {
let emails = if unseen {
client.fetch_unseen(folder).await?
} else if let Some(since_date) = since {
let before_date =
before.unwrap_or_else(|| chrono::Utc::now().date_naive() + chrono::Duration::days(1));
client
.fetch_date_range(folder, since_date, before_date)
.await?
} else {
client.fetch_last_n(folder, limit).await?
};
let display: Vec<&Email> = emails.iter().take(limit).collect();
if args.json {
println!("{}", serde_json::to_string_pretty(&display)?);
} else {
print_email_table(&display);
}
Ok(())
}
async fn cmd_show(
client: &ProtonClient,
args: &Args,
folder: &Folder,
uid: u32,
) -> anyhow::Result<()> {
let email = client.fetch_uid(folder, uid).await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&email)?);
} else {
print_email_detail(&email);
}
Ok(())
}
async fn cmd_folders(client: &ProtonClient, args: &Args) -> anyhow::Result<()> {
let folders = client.list_folders().await?;
if args.json {
println!("{}", serde_json::to_string_pretty(&folders)?);
} else {
for folder in &folders {
println!("{folder}");
}
}
Ok(())
}
async fn cmd_search(
client: &ProtonClient,
args: &Args,
folder: &Folder,
query: &str,
limit: usize,
) -> anyhow::Result<()> {
let emails = client.search(folder, query).await?;
let display: Vec<&Email> = emails.iter().take(limit).collect();
if args.json {
println!("{}", serde_json::to_string_pretty(&display)?);
} else {
print_email_table(&display);
}
Ok(())
}
fn print_email_table(emails: &[&Email]) {
if emails.is_empty() {
println!("No emails found.");
return;
}
let header = format!("{:<8} {:<20} {:<30} {}", "UID", "Date", "From", "Subject");
println!("{header}");
println!("{}", "-".repeat(100));
for email in emails {
println!(
"{:<8} {:<20} {:<30} {}",
email.uid,
email.date.format("%Y-%m-%d %H:%M"),
truncate(&email.from.to_string(), 28),
truncate(&email.subject.original, 40),
);
}
println!("\n{} email(s)", emails.len());
}
fn print_email_detail(email: &Email) {
println!("UID: {}", email.uid);
println!("Date: {}", email.date.format("%Y-%m-%d %H:%M:%S"));
println!("From: {}", email.from);
println!(
"To: {}",
email
.to
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
if !email.cc.is_empty() {
println!(
"CC: {}",
email
.cc
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
}
println!("Subject: {}", email.subject.original);
println!("Msg-ID: {}", email.message_id);
if email.thread.is_reply {
println!(
"Reply-To-ID: {}",
email
.thread
.in_reply_to
.as_ref()
.map_or("-", |id| id.as_str())
);
}
println!("\n--- Body ---\n");
println!("{}", email.body.best_text());
if !email.extracted.emails.is_empty() {
println!("\n--- Extracted Emails ---");
for e in &email.extracted.emails {
println!(" {}", e.address);
}
}
if !email.extracted.urls.is_empty() {
println!("\n--- Extracted URLs ---");
for u in &email.extracted.urls {
println!(" {}", u.url);
}
}
if !email.extracted.phone_numbers.is_empty() {
println!("\n--- Extracted Phones ---");
for p in &email.extracted.phone_numbers {
println!(" {}", p.raw);
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{truncated}...")
}
}