use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::Receiver;
use std::time::Duration;
use anyhow::{Context as _, Result, bail};
use clap::{Parser, Subcommand};
use postvak_core::account;
use postvak_core::config::{AccountConfig, Config, Identity, PasswordSource};
use postvak_core::credentials::Secret;
use postvak_core::daemon::Client;
use postvak_core::daemon::client::LazyClient;
use postvak_core::daemon::wire::Status;
use postvak_core::message::clean;
use postvak_core::paths::Paths;
use postvak_core::rules::{Action, CompiledRule, Rule, RuleFile};
use postvak_core::store::{Filter, Message, Store};
use postvak_core::sync::{self, Event};
use postvak_core::time;
use postvak_core::trash::Trash;
#[derive(Parser)]
#[command(
name = "postvak",
version,
about = "A fast, simple mail client with automatic mailbox rules"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(clap::Args)]
struct Selection {
#[arg(required = true)]
uids: Vec<u32>,
#[arg(long)]
account: Option<String>,
#[arg(long, default_value = "INBOX")]
folder: String,
#[arg(long)]
dry_run: bool,
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum Mark {
Flag,
Read,
Unflag,
Unread,
}
#[derive(Subcommand)]
enum Command {
Run {
#[arg(long, hide = true)]
idle_exit: Option<u64>,
},
Daemon {
#[command(subcommand)]
command: DaemonCommand,
},
Gui {
#[arg(long)]
safe_mode: bool,
},
Service {
#[command(subcommand)]
command: ServiceCommand,
},
#[command(about = postvak_core::help::SYNC)]
Sync {
#[arg(long)]
account: Option<String>,
},
Attachment {
#[command(subcommand)]
command: AttachmentCommand,
},
Rules {
#[command(subcommand)]
command: RulesCommand,
},
#[command(about = postvak_core::help::FOLDERS)]
Folders {
#[arg(long)]
account: Option<String>,
#[arg(long)]
json: bool,
},
#[command(about = postvak_core::help::LIST)]
List {
#[arg(long)]
account: Option<String>,
#[arg(long, default_value = "INBOX")]
folder: String,
#[arg(long, default_value_t = 50)]
limit: u32,
#[arg(long)]
json: bool,
#[arg(long)]
threads: bool,
#[arg(long)]
unread: bool,
#[arg(long)]
flagged: bool,
#[arg(long)]
attachments: bool,
},
#[command(about = postvak_core::help::SEARCH)]
Search {
query: String,
#[arg(long)]
account: Option<String>,
#[arg(long)]
folder: Option<String>,
#[arg(long)]
bodies: bool,
#[arg(long)]
server: bool,
#[arg(long, default_value_t = 50)]
limit: u32,
#[arg(long)]
json: bool,
},
#[command(about = postvak_core::help::SHOW)]
Show {
uid: u32,
#[arg(long)]
account: Option<String>,
#[arg(long, default_value = "INBOX")]
folder: String,
#[arg(long)]
raw: bool,
#[arg(long)]
json: bool,
},
#[command(about = postvak_core::help::UNSUBSCRIBE)]
Unsubscribe {
uid: u32,
#[arg(long)]
account: Option<String>,
#[arg(long, default_value = "INBOX")]
folder: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
json: bool,
},
#[command(about = postvak_core::help::MARK)]
Mark {
#[arg(value_enum)]
how: Mark,
#[command(flatten)]
selection: Selection,
},
#[command(about = postvak_core::help::MOVE)]
Move {
#[arg(long)]
to: String,
#[command(flatten)]
selection: Selection,
},
#[command(about = postvak_core::help::ARCHIVE)]
Archive {
#[command(flatten)]
selection: Selection,
},
#[command(about = postvak_core::help::DELETE)]
Delete {
#[command(flatten)]
selection: Selection,
},
#[command(about = postvak_core::help::JUNK)]
Junk {
#[command(flatten)]
selection: Selection,
#[arg(long)]
not: bool,
},
#[command(about = postvak_core::help::LOG)]
Log {
#[arg(long)]
account: Option<String>,
#[arg(long, default_value_t = 50)]
limit: u32,
#[arg(long)]
json: bool,
},
Trash {
#[command(subcommand)]
command: TrashCommand,
},
Account {
#[command(subcommand)]
command: AccountCommand,
},
Crashes {
#[command(subcommand)]
command: Option<CrashesCommand>,
},
Guide,
#[command(args_conflicts_with_subcommands = true)]
Mcp {
#[arg(long, default_value = postvak_core::help::MCP_DEFAULT_SCOPES)]
scopes: String,
#[arg(long)]
account: Vec<String>,
#[command(subcommand)]
command: Option<McpCommand>,
},
}
#[derive(Subcommand)]
enum CrashesCommand {
Show { id: String },
}
#[derive(Subcommand)]
enum DaemonCommand {
#[command(about = postvak_core::help::STATUS)]
Status {
#[arg(long)]
json: bool,
},
Stop,
}
#[derive(Subcommand)]
enum ServiceCommand {
Install {
#[arg(long)]
dry_run: bool,
},
Remove {
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand)]
enum McpCommand {
Install {
#[arg(value_enum)]
target: InstallTarget,
#[arg(long, default_value = postvak_core::help::MCP_DEFAULT_SCOPES)]
scopes: String,
#[arg(long)]
account: Vec<String>,
#[arg(long)]
remove: bool,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum InstallTarget {
ClaudeCode,
ClaudeDesktop,
Json,
}
#[derive(Subcommand)]
enum AttachmentCommand {
#[command(about = postvak_core::help::ATTACHMENT_LIST)]
List {
uid: u32,
#[arg(long)]
account: Option<String>,
#[arg(long, default_value = "INBOX")]
folder: String,
#[arg(long)]
json: bool,
},
#[command(about = postvak_core::help::ATTACHMENT_SAVE)]
Save {
uid: u32,
n: usize,
#[arg(long, default_value = ".")]
dir: PathBuf,
#[arg(long)]
account: Option<String>,
#[arg(long, default_value = "INBOX")]
folder: String,
},
}
#[derive(Subcommand)]
enum RulesCommand {
#[command(about = postvak_core::help::RULES_CHECK)]
Check,
Test {
#[arg(conflicts_with = "stdin")]
name: Option<String>,
#[arg(long)]
account: Option<String>,
#[arg(long)]
stdin: bool,
},
#[command(about = postvak_core::help::RULES_SCHEMA)]
Schema,
Propose {
#[arg(long)]
by: Option<String>,
},
#[command(about = postvak_core::help::RULES_APPROVE)]
Approve { name: String },
#[command(about = postvak_core::help::RULES_REJECT)]
Reject { name: String },
#[command(about = postvak_core::help::RULES_LIST)]
List {
#[arg(long)]
json: bool,
},
#[command(about = postvak_core::help::RULES_APPLY_EXISTING)]
ApplyExisting {
name: String,
#[arg(long)]
account: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
yes: bool,
},
}
#[derive(Subcommand)]
enum TrashCommand {
#[command(about = postvak_core::help::TRASH_LIST)]
List {
#[arg(long)]
account: Option<String>,
},
#[command(about = postvak_core::help::TRASH_RESTORE)]
Restore {
file: String,
#[arg(long)]
account: Option<String>,
},
#[command(about = postvak_core::help::TRASH_PURGE)]
Purge {
#[arg(long)]
account: Option<String>,
},
}
#[derive(Subcommand)]
enum AccountCommand {
#[command(about = postvak_core::help::ACCOUNT_ADD)]
Add,
#[command(about = postvak_core::help::ACCOUNT_LIST)]
List {
#[arg(long)]
json: bool,
},
}
pub fn run() -> Result<()> {
let cli = if opened_as_mac_app() {
Cli {
command: Command::Gui { safe_mode: false },
}
} else {
Cli::parse()
};
let paths = match std::env::var_os("POSTVAK_HOME") {
Some(home) => Ok(Paths::under(Path::new(&home))),
None => Paths::discover(),
};
if let Ok(paths) = &paths {
postvak_core::crash::install(paths.crash_dir(), process(&cli.command));
}
if let Command::Gui { safe_mode } = cli.command {
return cmd_gui(paths, safe_mode);
}
let paths = paths?;
let config = Config::load(&paths.config_file())?;
match cli.command {
Command::Run { idle_exit } => cmd_run(&paths, idle_exit),
Command::Crashes { command } => cmd_crashes(command, &paths),
Command::Daemon { command } => cmd_daemon(command, &paths),
Command::Gui { safe_mode } => cmd_gui(Ok(paths), safe_mode),
Command::Service { command } => cmd_service(command, &paths),
Command::Sync { account } => cmd_sync(&config, &paths, account.as_deref()),
Command::Attachment { command } => cmd_attachment(command, &config, &paths),
Command::Rules { command } => cmd_rules(command, &config, &paths),
Command::Folders { account, json } => {
for acc in select_accounts(&config, account.as_deref())? {
let store = Store::open_account(&paths, &acc.name)?;
for f in store.folders()? {
let total = store.message_count(&f.name)?;
let unread = store.unread_count(&f.name)?;
if json {
print_json(postvak_core::output::folder(&acc.name, &f, total, unread));
} else {
println!("{}\t{}\t{total}\t{unread}", acc.name, clean(&f.name, false));
}
}
}
Ok(())
}
Command::List {
account,
folder,
limit,
json,
threads,
unread,
flagged,
attachments,
} => {
let filter = Filter {
unread,
flagged,
attachments,
};
for acc in select_accounts(&config, account.as_deref())? {
let store = Store::open_account(&paths, &acc.name)?;
if threads {
for thread in store.threads(&folder, limit, filter)? {
for (m, depth) in thread.iter().zip(postvak_core::output::depths(&thread)) {
if json {
let mut value = serde_json::to_value(m)?;
value["depth"] = depth.into();
print_json(postvak_core::output::with_account(&acc.name, &value)?);
} else {
println!("{}", message_line(&acc.name, m, depth));
}
}
}
continue;
}
for m in store.messages(&folder, limit, filter)? {
if json {
print_json(postvak_core::output::with_account(&acc.name, &m)?);
} else {
println!("{}", message_line(&acc.name, &m, 0));
}
}
}
Ok(())
}
Command::Search {
query,
account,
folder,
bodies,
server,
limit,
json,
} => {
let daemon = LazyClient::new(&paths);
for acc in select_accounts(&config, account.as_deref())? {
let store = Store::open_account(&paths, &acc.name)?;
if bodies && store.lacks_bodies(folder.as_deref())? {
eprintln!(
"{}: fetching missing bodies; this can take a while on a large folder",
acc.name
);
if let Err(e) = fetch_bodies(&daemon, &acc.name, folder.as_deref()) {
eprintln!(
"{}: {}; searching the bodies already stored",
acc.name,
clean(&format!("{e:#}"), false)
);
}
}
let mut found = store.search(&query, folder.as_deref(), limit)?;
if server {
match daemon.search_server(&acc.name, folder.as_deref(), &query) {
Ok(hits) => {
found = postvak_core::actions::with_server_hits(
&store, found, &hits, limit,
)?
}
Err(e) => eprintln!(
"{}: {}; showing local results only",
acc.name,
clean(&format!("{e:#}"), false)
),
}
}
for m in found {
if json {
print_json(postvak_core::output::with_account(&acc.name, &m)?);
} else {
println!("{}", message_line(&acc.name, &m, 0));
}
}
}
Ok(())
}
Command::Unsubscribe {
uid,
account,
folder,
dry_run,
json,
} => {
let (acc, _store, msg) =
open_message(&config, &paths, account.as_deref(), &folder, uid)?;
let report = postvak_core::unsubscribe::unsubscribe(&msg.headers, dry_run)?;
if json {
print_json(postvak_core::output::with_account(&acc.name, &report)?);
} else {
println!("{}", unsubscribe_line(&report));
}
Ok(())
}
Command::Show {
uid,
account,
folder,
raw,
json,
} => {
let (acc, store, msg) =
open_message(&config, &paths, account.as_deref(), &folder, uid)?;
let body = LazyClient::new(&paths).raw_message(&acc.name, &store, &msg)?;
if raw {
io::stdout().write_all(&body)?;
} else if json {
print_json(
serde_json::json!({ "account": acc.name, "message": msg, "body_text": postvak_core::message::body_text(&body) }),
);
} else {
println!(
"From: {}\nTo: {}\nSubject: {}\n",
clean(msg.from_addr.as_deref().unwrap_or(""), false),
clean(msg.to_addr.as_deref().unwrap_or(""), false),
clean(msg.subject.as_deref().unwrap_or(""), false)
);
println!("{}", clean(&postvak_core::message::body_text(&body), true));
}
Ok(())
}
Command::Mark { how, selection } => {
let action = match how {
Mark::Flag => Action::Flag,
Mark::Read => Action::MarkRead,
Mark::Unflag => Action::Unflag,
Mark::Unread => Action::MarkUnread,
};
cmd_act(&config, &paths, selection, action)
}
Command::Move { to, selection } => {
if to.trim().is_empty() {
bail!("--to must name a folder");
}
cmd_act(&config, &paths, selection, Action::Move(to))
}
Command::Archive { selection } => cmd_act(&config, &paths, selection, Action::Archive),
Command::Delete { selection } => cmd_act(&config, &paths, selection, Action::Trash),
Command::Junk { selection, not } => {
let action = if not { Action::NotJunk } else { Action::Junk };
cmd_act(&config, &paths, selection, action)
}
Command::Log {
account,
limit,
json,
} => {
for acc in select_accounts(&config, account.as_deref())? {
let store = Store::open_account(&paths, &acc.name)?;
for e in store.log(limit)? {
if json {
print_json(postvak_core::output::with_account(&acc.name, &e)?);
} else {
println!(
"{} {} {:<20} {:<12} {}/{} {}",
acc.name,
time::date_time(e.at),
clean(&e.rule_name, false),
clean(&e.action, false),
clean(&e.folder, false),
e.uid,
clean(e.subject.as_deref().unwrap_or(""), false)
);
}
}
}
Ok(())
}
Command::Trash { command } => cmd_trash(command, &config, &paths),
Command::Account { command } => match command {
AccountCommand::Add => cmd_account_add(config, &paths),
AccountCommand::List { json } => {
for acc in &config.accounts {
if json {
print_json(postvak_core::output::account(acc));
} else {
println!(
"{}\t{}\t{}:{}",
acc.name,
clean(&acc.username, false),
acc.host,
acc.port
);
}
}
Ok(())
}
},
Command::Guide => {
print!("{}", postvak_core::help::AGENT_GUIDE);
Ok(())
}
Command::Mcp {
scopes,
account,
command,
} => cmd_mcp(&config, &paths, &scopes, &account, command),
}
}
fn cmd_act(config: &Config, paths: &Paths, selection: Selection, action: Action) -> Result<()> {
let acc = single_account(config, selection.account.as_deref())?;
let store = Store::open_account(paths, &acc.name)?;
let folder = clean(&selection.folder, false);
if selection.dry_run {
let mut missing = 0;
for &uid in &selection.uids {
match store.message(&selection.folder, uid)? {
Some(m) => println!(
"{} {folder}/{uid} {}",
postvak_core::actions::planned_effect(&store, &m, &action)?,
clean(m.subject.as_deref().unwrap_or(""), false)
),
None => {
eprintln!("{folder}/{uid}: not in the local store");
missing += 1;
}
}
}
if missing > 0 {
bail!("{missing} messages are not in the local store; run `postvak sync` first");
}
return Ok(());
}
let results = LazyClient::new(paths).apply(
&acc.name,
&selection.folder,
&selection.uids,
&action,
postvak_core::actions::RULE_NAME,
)?;
let mut failed = 0;
for (uid, result) in &results {
if let Err(message) = result {
eprintln!("{folder}/{uid}: {}", clean(message, false));
failed += 1;
}
}
println!(
"{}: {} of {} messages",
clean(&action.command_label(), false),
results.len() - failed,
results.len()
);
if failed > 0 {
bail!("{failed} of {} messages failed", results.len());
}
Ok(())
}
fn unsubscribe_line(report: &postvak_core::unsubscribe::Report) -> String {
let url = clean(&report.url, false);
match (report.method, report.done) {
(_, true) => "unsubscribed: the sender's server accepted it".to_string(),
("one_click", false) => format!("would unsubscribe by one-click POST to {url}"),
("mail", false) => format!("send a mail to unsubscribe: {url}"),
_ => format!("open this page to unsubscribe: {url}"),
}
}
fn fetch_bodies(daemon: &LazyClient, account: &str, folder: Option<&str>) -> Result<()> {
let fetch = sync::Command::FetchBodies {
folder: folder.map(str::to_string),
};
daemon.request(account, fetch, |reply| match reply {
Event::BodiesFetched { .. } => Ok(()),
other => Err(other),
})
}
fn cmd_sync(config: &Config, paths: &Paths, account: Option<&str>) -> Result<()> {
let accounts = select_accounts(config, account)?;
if accounts.is_empty() {
return Ok(());
}
let names: Vec<&str> = accounts.iter().map(|acc| acc.name.as_str()).collect();
let client = Client::connect_or_start(paths)?;
let events = client.subscribe()?;
std::thread::scope(|scope| {
let printer = scope.spawn(|| print_new_mail(events, &names));
let mut failed = false;
for name in &names {
failed |= !sync_account(&client, name);
}
drop(client);
let _ = printer.join();
if failed {
bail!("sync failed for at least one account or folder");
}
Ok(())
})
}
fn print_new_mail(events: Receiver<Event>, accounts: &[&str]) {
for event in &events {
if let Event::NewMail { account, .. } = &event
&& accounts.contains(&account.as_str())
{
postvak_core::daemon::report(&event);
}
}
}
fn sync_account(client: &Client, name: &str) -> bool {
match client.request(name, sync::Command::SyncNow) {
Ok(event) => {
postvak_core::daemon::report(&event);
let errors = match &event {
Event::Synced { errors, .. } => errors.as_slice(),
_ => &[],
};
for message in errors {
postvak_core::daemon::report_error(name, message);
}
errors.is_empty()
}
Err(e) => {
postvak_core::daemon::report_error(name, &format!("{e:#}"));
false
}
}
}
fn print_status(status: &Status) {
let minutes = status.uptime_secs / 60;
println!(
"pid {}, version {}, up {}h {}m, {} clients",
status.pid,
status.version,
minutes / 60,
minutes % 60,
status.clients
);
for account in &status.accounts {
let activity = account
.activity
.as_ref()
.map_or("starting".into(), ToString::to_string);
println!("{} {activity}", account.name);
}
}
fn cmd_daemon(command: DaemonCommand, paths: &Paths) -> Result<()> {
let Some(client) = Client::connect_any_version(paths)? else {
let json = matches!(command, DaemonCommand::Status { json: true });
println!("{}", if json { "null" } else { "no daemon running" });
return Ok(());
};
match command {
DaemonCommand::Status { json } => {
let status = client.status()?;
if json {
print_json(postvak_core::output::status(&status)?);
} else {
print_status(&status);
}
}
DaemonCommand::Stop => {
let pid = client.stop_and_wait(paths)?;
println!("stopped the daemon (pid {pid})");
}
}
Ok(())
}
fn cmd_service(command: ServiceCommand, paths: &Paths) -> Result<()> {
let text = match command {
ServiceCommand::Install { dry_run } => {
postvak_core::daemon::service::install(paths, dry_run)?
}
ServiceCommand::Remove { dry_run } => postvak_core::daemon::service::remove(dry_run)?,
};
println!("{text}");
Ok(())
}
fn cmd_attachment(command: AttachmentCommand, config: &Config, paths: &Paths) -> Result<()> {
let (uid, account, folder) = match &command {
AttachmentCommand::List {
uid,
account,
folder,
..
}
| AttachmentCommand::Save {
uid,
account,
folder,
..
} => (*uid, account.as_deref(), folder.as_str()),
};
let (acc, store, msg) = open_message(config, paths, account, folder, uid)?;
let raw = LazyClient::new(paths).raw_message(&acc.name, &store, &msg)?;
match command {
AttachmentCommand::List { json, .. } => {
for a in postvak_core::message::attachments(&raw) {
if json {
print_json(postvak_core::output::with_account(&acc.name, &a)?);
} else {
println!(
"{} {} {} {}",
a.index,
clean(&a.content_type, false),
a.size,
clean(a.name.as_deref().unwrap_or("-"), false)
);
}
}
}
AttachmentCommand::Save { n, dir, .. } => {
let path = postvak_core::message::save_attachment(&raw, n, &dir)
.with_context(|| format!("saving attachment {n}"))?;
println!("{}", clean(&path.display().to_string(), false));
}
}
Ok(())
}
fn select_accounts<'a>(config: &'a Config, name: Option<&str>) -> Result<Vec<&'a AccountConfig>> {
match name {
Some(n) => Ok(vec![
config
.account(n)
.with_context(|| format!("no account named '{n}'"))?,
]),
None => Ok(config.accounts.iter().collect()),
}
}
fn single_account<'a>(config: &'a Config, name: Option<&str>) -> Result<&'a AccountConfig> {
match (name, config.accounts.len()) {
(Some(n), _) => config
.account(n)
.with_context(|| format!("no account named '{n}'")),
(None, 1) => Ok(&config.accounts[0]),
(None, 0) => bail!("no accounts configured; run `postvak account add`"),
(None, _) => bail!("several accounts configured; pass --account"),
}
}
fn open_message<'a>(
config: &'a Config,
paths: &Paths,
account: Option<&str>,
folder: &str,
uid: u32,
) -> Result<(&'a AccountConfig, Store, Message)> {
let acc = single_account(config, account)?;
let store = Store::open_account(paths, &acc.name)?;
let msg = store
.message(folder, uid)?
.with_context(|| format!("no message {}/{uid}", clean(folder, false)))?;
Ok((acc, store, msg))
}
fn print_json(value: serde_json::Value) {
println!("{}", postvak_core::output::clean_value(value, false));
}
fn message_line(account: &str, m: &Message, depth: usize) -> String {
format!(
"{account} {} {:<14} {} {:<30} {}{}",
if m.is_seen() { " " } else { "*" },
clean(&format!("{}/{}", m.folder, m.uid), false),
time::date_time(m.internaldate),
truncate(&clean(m.from_addr.as_deref().unwrap_or(""), false), 30),
" ".repeat(depth),
clean(m.subject.as_deref().unwrap_or(""), false)
)
}
fn truncate(s: &str, width: usize) -> String {
let mut out: String = s.chars().take(width).collect();
if s.chars().count() > width {
out.pop();
out.push('…');
}
out
}
fn process(command: &Command) -> &'static str {
match command {
Command::Gui { .. } => "gui",
Command::Mcp { command: None, .. } => "mcp",
Command::Run { .. } => "daemon",
_ => "cli",
}
}
fn cmd_crashes(command: Option<CrashesCommand>, paths: &Paths) -> Result<()> {
let dir = paths.crash_dir();
match command {
None => {
let ids = postvak_core::crash::list(&dir)?;
if ids.is_empty() {
eprintln!("no crash reports in {}", dir.display());
}
for id in ids {
println!("{id}");
}
}
Some(CrashesCommand::Show { id }) => print!("{}", postvak_core::crash::show(&dir, &id)?),
}
Ok(())
}
fn opened_as_mac_app() -> bool {
use std::io::IsTerminal as _;
cfg!(target_os = "macos")
&& std::env::current_exe().is_ok_and(|exe| {
let args: Vec<_> = std::env::args_os().skip(1).collect();
postvak_core::paths::launched_as_app(&exe, &args, io::stdin().is_terminal())
})
}
#[cfg(feature = "gui")]
fn cmd_gui(paths: Result<Paths>, safe_mode: bool) -> Result<()> {
postvak_gui::run(paths, safe_mode)
}
#[cfg(not(feature = "gui"))]
fn cmd_gui(_paths: Result<Paths>, _safe_mode: bool) -> Result<()> {
bail!("this postvak was built without the GUI; install it with the default features")
}
#[cfg(feature = "mcp")]
fn cmd_mcp(
config: &Config,
paths: &Paths,
scopes: &str,
accounts: &[String],
command: Option<McpCommand>,
) -> Result<()> {
match command {
None => postvak_mcp::run(config, paths, scopes, accounts),
Some(McpCommand::Install {
target,
scopes,
account,
remove,
dry_run,
}) => {
use postvak_mcp::install::{Target, install};
if let Some(name) = account.iter().find(|name| config.account(name).is_none()) {
bail!("no account named '{name}'");
}
let target = match target {
InstallTarget::ClaudeCode => Target::ClaudeCode,
InstallTarget::ClaudeDesktop => Target::ClaudeDesktop,
InstallTarget::Json => Target::Json,
};
let (stdout, hint) = install(target, &scopes, &account, remove, dry_run)?;
print!("{stdout}");
if let Some(hint) = hint {
eprint!("{hint}");
}
Ok(())
}
}
}
#[cfg(not(feature = "mcp"))]
fn cmd_mcp(
_config: &Config,
_paths: &Paths,
_scopes: &str,
_accounts: &[String],
_command: Option<McpCommand>,
) -> Result<()> {
bail!("this postvak was built without the MCP server; install it with the default features")
}
fn cmd_run(paths: &Paths, idle_exit: Option<u64>) -> Result<()> {
let override_secs = std::env::var("POSTVAK_IDLE_EXIT_SECS")
.ok()
.and_then(|secs| secs.parse().ok());
let options = match idle_exit.map(|secs| override_secs.unwrap_or(secs)) {
Some(secs) => postvak_core::daemon::Options::auto_started(Duration::from_secs(secs)),
None => postvak_core::daemon::Options::foreground(),
};
postvak_core::daemon::run(paths, options)
}
fn cmd_rules(command: RulesCommand, config: &Config, paths: &Paths) -> Result<()> {
match command {
RulesCommand::Check => {
println!("{} rules ok", compiled_rules(paths, None)?.len());
Ok(())
}
RulesCommand::List { json } => {
let file = postvak_core::rules::load(&paths.rules_file())?;
for r in &file.rules {
if json {
print_json(postvak_core::output::rule(r));
} else {
println!(
"{}\t{}\t{}",
if r.enabled { "on " } else { "off" },
clean(&r.name, false),
clean(r.proposed_by.as_deref().unwrap_or(""), false)
);
}
}
Ok(())
}
RulesCommand::ApplyExisting {
name,
account,
dry_run,
yes,
} => {
let rules = vec![postvak_core::rules::enabled_rule(
&paths.rules_file(),
&name,
)?];
let accounts: Vec<&AccountConfig> = select_accounts(config, account.as_deref())?
.into_iter()
.filter(|acc| rules.iter().any(|r| r.applies_to_account(&acc.name)))
.collect();
if dry_run {
for acc in accounts {
let store = Store::open_account(paths, &acc.name)?;
print_planned_actions(&rules, &store, acc, &acc.identity()?)?;
}
return Ok(());
}
if !yes {
confirm_expunges(&rules, paths, &accounts)?;
}
let mut failed = false;
let daemon = LazyClient::new(paths);
for acc in accounts {
let (evaluated, actions, errors) = daemon.apply_rule(&acc.name, &name)?;
failed |= !errors.is_empty();
for message in &errors {
postvak_core::daemon::report_error(&acc.name, message);
}
println!("{}: {actions} actions on {evaluated} messages", acc.name);
}
if failed {
bail!("some actions failed; see the errors above");
}
Ok(())
}
RulesCommand::Test {
name,
account,
stdin,
} => {
let rules = if stdin {
let mut rule = read_rule_json()?;
rule.enabled = true;
postvak_core::rules::compile(&RuleFile { rules: vec![rule] })?
} else {
let mut rules = compiled_rules(paths, name.as_deref())?;
if name.is_some() {
rules.iter_mut().for_each(|r| r.rule.enabled = true);
}
rules
};
for acc in select_accounts(config, account.as_deref())? {
let store = Store::open_account(paths, &acc.name)?;
print_planned_actions(&rules, &store, acc, &acc.identity()?)?;
}
Ok(())
}
RulesCommand::Schema => {
print!("{}", postvak_core::rules::schema());
Ok(())
}
RulesCommand::Propose { by } => {
let rule = read_rule_json()?;
let name = rule.name.clone();
let by = by.map_or_else(|| "cli".to_string(), |who| format!("cli:{who}"));
postvak_core::rules::edit::propose(&paths.rules_file(), rule, &by)?;
println!(
"proposed '{}'; it stays disabled until a human runs `postvak rules approve`",
clean(&name, false)
);
Ok(())
}
RulesCommand::Approve { name } => {
let accounts: Vec<String> =
config.accounts.iter().map(|acc| acc.name.clone()).collect();
postvak_core::rules::edit::approve_from_now(paths, &accounts, &name, time::now())?;
println!(
"enabled '{}'; it acts on mail that arrives from now on",
clean(&name, false)
);
Ok(())
}
RulesCommand::Reject { name } => {
postvak_core::rules::edit::reject(&paths.rules_file(), &name)?;
println!("removed proposal '{}'", clean(&name, false));
Ok(())
}
}
}
fn read_rule_json() -> Result<Rule> {
serde_json::from_reader(io::stdin().lock()).context("reading one rule as JSON from stdin")
}
fn compiled_rules(paths: &Paths, name: Option<&str>) -> Result<Vec<CompiledRule>> {
let file = postvak_core::rules::load(&paths.rules_file())?;
let compiled: Vec<CompiledRule> = postvak_core::rules::compile(&file)?
.into_iter()
.filter(|r| name.is_none_or(|n| n == r.rule.name))
.collect();
if let Some(name) = name
&& compiled.is_empty()
{
bail!("no rule named '{name}'");
}
Ok(compiled)
}
fn confirm_expunges(
rules: &[CompiledRule],
paths: &Paths,
accounts: &[&AccountConfig],
) -> Result<()> {
use std::io::IsTerminal as _;
let mut any = false;
for acc in accounts {
let store = Store::open_account(paths, &acc.name)?;
let warning = postvak_core::actions::expunge_warning(
rules,
&store,
acc,
&acc.identity()?,
time::now(),
)?;
if let Some(warning) = warning {
any = true;
eprintln!("{warning}");
}
}
if !any {
return Ok(());
}
if !io::stdin().is_terminal() {
bail!(
"refusing to delete without confirmation; check with --dry-run, then rerun with --yes"
);
}
if prompt("Type yes to continue")? != "yes" {
bail!("cancelled");
}
Ok(())
}
fn print_planned_actions(
rules: &[CompiledRule],
store: &Store,
account: &AccountConfig,
identity: &Identity,
) -> Result<()> {
for p in postvak_core::actions::planned(rules, store, account, identity, time::now())? {
println!(
"{}\t{}/{}\t{}\t{}",
clean(&p.rule, false),
clean(&p.message.folder, false),
p.message.uid,
clean(&p.action.label(), false),
clean(p.message.subject.as_deref().unwrap_or(""), false)
);
}
Ok(())
}
fn cmd_trash(command: TrashCommand, config: &Config, paths: &Paths) -> Result<()> {
match command {
TrashCommand::List { account } => {
for acc in select_accounts(config, account.as_deref())? {
for e in Trash::new(paths.trash_dir(&acc.name)).list()? {
let subject = e.headers().subject.unwrap_or_default();
println!(
"{} {} {}/{} {} {}",
acc.name,
time::date_time(e.saved_at),
clean(&e.folder, false),
e.uid,
clean(&subject, false),
clean(&e.path.display().to_string(), false)
);
}
}
Ok(())
}
TrashCommand::Restore { file, account } => {
let acc = single_account(config, account.as_deref())?;
let folder = LazyClient::new(paths).restore(&acc.name, std::path::absolute(&file)?)?;
println!(
"restored to {}; rules leave restored mail alone",
clean(&folder, false)
);
Ok(())
}
TrashCommand::Purge { account } => {
for acc in select_accounts(config, account.as_deref())? {
let removed = Trash::new(paths.trash_dir(&acc.name))
.purge(acc.trash_retention_days, time::now())?;
println!("{}: removed {removed}", acc.name);
}
Ok(())
}
}
}
fn cmd_account_add(mut config: Config, paths: &Paths) -> Result<()> {
let name = prompt("Account name (letters, digits, - _)")?;
let host = prompt("IMAP host")?;
let port: u16 = prompt("Port [993]")?.parse().unwrap_or(993);
let ca_file = {
let answer = prompt("Extra trusted CA file (PEM, absolute path) [none]")?;
if answer.is_empty() {
None
} else {
Some(PathBuf::from(answer))
}
};
let username = prompt("Username")?;
let address = {
let a = prompt(&format!("Email address [{username}]"))?;
if a.is_empty() { None } else { Some(a) }
};
let storage = prompt("Password storage: (k)eyring or (c)ommand [k]")?;
let password = if storage.starts_with('c') {
PasswordSource::Command {
command: prompt("Password command")?,
}
} else {
PasswordSource::Keyring { keyring: true }
};
let account = AccountConfig {
port,
address,
ca_file,
..AccountConfig::new(&name, &host, &username, password)
};
account::check(&config, &account)?;
let secret = match &account.password {
PasswordSource::Command { .. } => None,
PasswordSource::Keyring { .. } => {
Some(Secret::new(rpassword::prompt_password("Password: ")?))
}
};
account::add(&mut config, paths, account, secret)?;
println!("saved to {}", paths.config_file().display());
let client = Client::connect_or_start(paths)?;
account::await_started(&client, &name, account::START_TIMEOUT)?;
if !sync_account(&client, &name) {
bail!(
"the first sync of '{name}' failed; check the login and run `postvak account add` again"
);
}
Ok(())
}
fn prompt(label: &str) -> Result<String> {
print!("{label}: ");
io::stdout().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
Ok(line.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_reference_is_current() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/src/cli.md");
let generated = clap_markdown::help_markdown::<Cli>();
if std::env::var_os("POSTVAK_BLESS").is_some() {
std::fs::write(path, &generated).unwrap();
}
assert_eq!(
std::fs::read_to_string(path).unwrap(),
generated,
"docs/src/cli.md is stale; run POSTVAK_BLESS=1 cargo test"
);
}
#[test]
fn unsubscribe_line_says_what_happened_or_what_to_open() {
use postvak_core::unsubscribe::Report;
let line = |method, done| {
unsubscribe_line(&Report {
method,
url: "https://list.example/u".into(),
done,
})
};
assert_eq!(
line("one_click", true),
"unsubscribed: the sender's server accepted it"
);
assert_eq!(
line("one_click", false),
"would unsubscribe by one-click POST to https://list.example/u"
);
assert_eq!(
line("link", false),
"open this page to unsubscribe: https://list.example/u"
);
assert!(line("mail", false).starts_with("send a mail to unsubscribe: "));
}
}