aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs133
-rw-r--r--src/client.rs92
-rw-r--r--src/commands.rs323
-rw-r--r--src/config.rs453
-rw-r--r--src/mailurl.rs126
-rw-r--r--src/main.rs61
-rw-r--r--src/message.rs168
-rw-r--r--src/output.rs98
8 files changed, 1454 insertions, 0 deletions
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..920f7bf
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,133 @@
+//! Command-line interface definition.
+
+use std::path::PathBuf;
+
+use clap::{Parser, Subcommand, ValueEnum};
+
+#[derive(Parser)]
+#[command(
+ name = "posta",
+ version,
+ about = "A tiny, generic IMAP/SMTP mail CLI",
+ long_about = "posta — manage any IMAP/SMTP mailbox from your terminal.\n\
+ Accounts live in a TOML config file; every command prints JSON \
+ by default so it plays nicely with jq and scripts."
+)]
+pub struct Cli {
+ /// Config file (default: $POSTA_CONFIG or ~/.config/posta/config.toml)
+ #[arg(long, global = true, value_name = "FILE")]
+ pub config: Option<PathBuf>,
+
+ /// Account name from the config (default: $POSTA_ACCOUNT or `default_account`)
+ #[arg(long, short = 'a', global = true, value_name = "NAME")]
+ pub account: Option<String>,
+
+ /// Output format
+ #[arg(long, global = true, value_enum, default_value_t = Format::Json)]
+ pub format: Format,
+
+ #[command(subcommand)]
+ pub cmd: Cmd,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum Format {
+ Json,
+ Table,
+}
+
+#[derive(Subcommand)]
+pub enum Cmd {
+ /// List the accounts defined in the config file
+ Accounts,
+ /// List mailbox folders
+ Folders,
+ /// Message and unread counts for a folder
+ Status {
+ #[arg(long, default_value = "INBOX")]
+ folder: String,
+ },
+ /// List messages, newest first
+ List {
+ #[arg(long, default_value = "INBOX")]
+ folder: String,
+ /// Only unread messages (shorthand for --query UNSEEN)
+ #[arg(long)]
+ unread: bool,
+ /// Raw IMAP SEARCH query, e.g. 'FROM bank SINCE 1-Jul-2026'
+ #[arg(long)]
+ query: Option<String>,
+ #[arg(long, default_value_t = 20)]
+ max: usize,
+ },
+ /// Show a message by UID
+ Get {
+ uid: u32,
+ #[arg(long, default_value = "INBOX")]
+ folder: String,
+ /// Print the raw RFC 822 source
+ #[arg(long)]
+ raw: bool,
+ /// Prefer the text/html part over text/plain
+ #[arg(long)]
+ html: bool,
+ /// Mark the message as read after fetching it
+ #[arg(long)]
+ mark_read: bool,
+ },
+ /// Send a message (body from --body, --body-file, or stdin)
+ Send {
+ #[arg(long, required = true)]
+ to: Vec<String>,
+ #[arg(long)]
+ cc: Vec<String>,
+ #[arg(long)]
+ subject: String,
+ #[arg(long)]
+ body: Option<String>,
+ #[arg(long)]
+ body_file: Option<PathBuf>,
+ /// Skip saving a copy to the sent folder
+ #[arg(long)]
+ no_record: bool,
+ },
+ /// Add or remove message flags
+ Mark {
+ #[arg(value_enum)]
+ state: MarkState,
+ #[arg(required = true)]
+ uids: Vec<u32>,
+ #[arg(long, default_value = "INBOX")]
+ folder: String,
+ },
+ /// Move messages to another folder
+ Move {
+ #[arg(required = true)]
+ uids: Vec<u32>,
+ #[arg(long)]
+ to: String,
+ #[arg(long, default_value = "INBOX")]
+ folder: String,
+ },
+ /// Move messages to the trash folder
+ Delete {
+ #[arg(required = true)]
+ uids: Vec<u32>,
+ #[arg(long, default_value = "INBOX")]
+ folder: String,
+ },
+ /// Run an IMAP SEARCH query and print matching UIDs
+ Search {
+ query: String,
+ #[arg(long, default_value = "INBOX")]
+ folder: String,
+ },
+}
+
+#[derive(Clone, Copy, ValueEnum)]
+pub enum MarkState {
+ Read,
+ Unread,
+ Flagged,
+ Unflagged,
+}
diff --git a/src/client.rs b/src/client.rs
new file mode 100644
index 0000000..3d9b719
--- /dev/null
+++ b/src/client.rs
@@ -0,0 +1,92 @@
+//! IMAP connection and small protocol helpers.
+
+use std::net::TcpStream;
+
+use anyhow::{anyhow, Result};
+use native_tls::{TlsConnector, TlsStream};
+
+use crate::config::Account;
+
+pub type ImapSession = imap::Session<TlsStream<TcpStream>>;
+
+/// Opens a logged-in IMAP session, using STARTTLS or implicit TLS as configured.
+pub fn connect(acc: &Account) -> Result<ImapSession> {
+ let (ep, pass) = acc.imap()?;
+ let tls = TlsConnector::new()?;
+ let client = if ep.implicit_tls {
+ imap::connect((ep.host.as_str(), ep.port), ep.host.as_str(), &tls)?
+ } else {
+ imap::connect_starttls((ep.host.as_str(), ep.port), ep.host.as_str(), &tls)?
+ };
+ client.login(&ep.user, &pass).map_err(|e| {
+ anyhow!(
+ "IMAP login failed for {} (account '{}'): {:?}",
+ ep.user,
+ acc.name,
+ e.0
+ )
+ })
+}
+
+/// Quotes a mailbox name when it needs it (spaces, quotes, parentheses).
+pub fn quote_mailbox(name: &str) -> String {
+ if name.starts_with('"') || !name.contains([' ', '"', '(', ')']) {
+ name.to_string()
+ } else {
+ format!("\"{}\"", name.replace('"', "\\\""))
+ }
+}
+
+/// Renders a UID list as an IMAP sequence set.
+pub fn uid_set(uids: &[u32]) -> String {
+ uids.iter()
+ .map(u32::to_string)
+ .collect::<Vec<_>>()
+ .join(",")
+}
+
+/// Moves messages, preferring `UID MOVE` with a COPY+EXPUNGE fallback for
+/// servers without the MOVE capability.
+pub fn move_uids(session: &mut ImapSession, uids: &[u32], to: &str) -> Result<()> {
+ let set = uid_set(uids);
+ let dest = quote_mailbox(to);
+ if session.uid_mv(&set, &dest).is_ok() {
+ return Ok(());
+ }
+ session.uid_copy(&set, &dest)?;
+ session.uid_store(&set, "+FLAGS (\\Deleted)")?;
+ session.expunge()?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn plain_names_stay_unquoted() {
+ assert_eq!(quote_mailbox("INBOX"), "INBOX");
+ assert_eq!(quote_mailbox("Archives/2025"), "Archives/2025");
+ }
+
+ #[test]
+ fn names_with_spaces_get_quoted() {
+ assert_eq!(quote_mailbox("Sent Mail"), "\"Sent Mail\"");
+ }
+
+ #[test]
+ fn embedded_quotes_are_escaped() {
+ assert_eq!(quote_mailbox("a\"b"), "\"a\\\"b\"");
+ }
+
+ #[test]
+ fn already_quoted_names_pass_through() {
+ assert_eq!(quote_mailbox("\"Sent Mail\""), "\"Sent Mail\"");
+ }
+
+ #[test]
+ fn uid_sets_join_with_commas() {
+ assert_eq!(uid_set(&[7, 9, 11]), "7,9,11");
+ assert_eq!(uid_set(&[42]), "42");
+ }
+}
diff --git a/src/commands.rs b/src/commands.rs
new file mode 100644
index 0000000..f43c8a5
--- /dev/null
+++ b/src/commands.rs
@@ -0,0 +1,323 @@
+//! One function per subcommand.
+
+use std::collections::HashSet;
+use std::io::{Read as _, Write as _};
+use std::path::PathBuf;
+
+use anyhow::{anyhow, Context, Result};
+use serde_json::{json, Value};
+
+use crate::cli::{Format, MarkState};
+use crate::client::{self, quote_mailbox, uid_set, ImapSession};
+use crate::config::{self, Account};
+use crate::message::{extract_body, flag_to_string, summarize, MsgSummary};
+use crate::output::{message_table, print_json};
+
+fn sorted_desc(uids: HashSet<u32>) -> Vec<u32> {
+ let mut v: Vec<u32> = uids.into_iter().collect();
+ v.sort_unstable_by(|a, b| b.cmp(a));
+ v
+}
+
+fn fetch_summaries(session: &mut ImapSession, uids: &[u32]) -> Result<Vec<MsgSummary>> {
+ if uids.is_empty() {
+ return Ok(vec![]);
+ }
+ let fetches = session.uid_fetch(uid_set(uids), "(UID FLAGS RFC822.SIZE RFC822.HEADER)")?;
+ let mut items: Vec<MsgSummary> = fetches.iter().filter_map(summarize).collect();
+ items.sort_by_key(|m| std::cmp::Reverse(m.uid));
+ Ok(items)
+}
+
+/// `accounts` needs the raw config file, not a resolved account.
+pub fn accounts(cli_config: &Option<PathBuf>, format: Format) -> Result<()> {
+ let path = config::locate(cli_config).ok_or_else(|| {
+ anyhow!(
+ "no config file found (default: {})",
+ config::DEFAULT_CONFIG_PATH
+ )
+ })?;
+ let cfg = config::read_config_file(&path)?;
+ let list: Vec<Value> = cfg
+ .accounts
+ .iter()
+ .map(|(name, a)| {
+ json!({
+ "name": name,
+ "default": Some(name) == cfg.default_account.as_ref(),
+ "email": a.email,
+ "imap_url": a.imap_url,
+ "smtp_url": a.smtp_url,
+ })
+ })
+ .collect();
+ match format {
+ Format::Json => print_json(&json!({
+ "config": path.display().to_string(),
+ "accounts": list,
+ })),
+ Format::Table => {
+ for a in &list {
+ let mark = if a["default"].as_bool() == Some(true) {
+ "*"
+ } else {
+ " "
+ };
+ println!(
+ "{} {} {}",
+ mark,
+ a["name"].as_str().unwrap_or("?"),
+ a["email"].as_str().unwrap_or("-")
+ );
+ }
+ Ok(())
+ }
+ }
+}
+
+pub fn folders(acc: &Account, format: Format) -> Result<()> {
+ let mut s = client::connect(acc)?;
+ let names = s.list(Some(""), Some("*"))?;
+ let mut folders: Vec<Value> = names
+ .iter()
+ .map(|n| {
+ json!({
+ "name": n.name(),
+ "delimiter": n.delimiter(),
+ "attributes": n.attributes().iter().map(|a| format!("{a:?}")).collect::<Vec<_>>(),
+ })
+ })
+ .collect();
+ folders.sort_by_key(|f| f["name"].as_str().unwrap_or("").to_string());
+ let _ = s.logout();
+ match format {
+ Format::Json => print_json(&json!({ "folders": folders })),
+ Format::Table => {
+ for f in &folders {
+ println!("{}", f["name"].as_str().unwrap_or("?"));
+ }
+ Ok(())
+ }
+ }
+}
+
+pub fn status(acc: &Account, folder: &str, format: Format) -> Result<()> {
+ let mut s = client::connect(acc)?;
+ let mbox = s.examine(quote_mailbox(folder))?;
+ let unseen = s.uid_search("UNSEEN")?.len();
+ let _ = s.logout();
+ match format {
+ Format::Json => {
+ print_json(&json!({ "folder": folder, "messages": mbox.exists, "unread": unseen }))
+ }
+ Format::Table => {
+ println!("{folder}: {} messages, {} unread", mbox.exists, unseen);
+ Ok(())
+ }
+ }
+}
+
+pub fn list(
+ acc: &Account,
+ folder: &str,
+ unread: bool,
+ query: Option<String>,
+ max: usize,
+ format: Format,
+) -> Result<()> {
+ let query = query.unwrap_or_else(|| {
+ if unread {
+ "UNSEEN".into()
+ } else {
+ "ALL".into()
+ }
+ });
+ let mut s = client::connect(acc)?;
+ s.examine(quote_mailbox(folder))?;
+ let uids = sorted_desc(s.uid_search(&query)?);
+ let page: Vec<u32> = uids.into_iter().take(max).collect();
+ let items = fetch_summaries(&mut s, &page)?;
+ let _ = s.logout();
+ match format {
+ Format::Json => print_json(&json!({
+ "folder": folder,
+ "query": query,
+ "count": items.len(),
+ "messages": items,
+ })),
+ Format::Table => {
+ print!("{}", message_table(&items));
+ Ok(())
+ }
+ }
+}
+
+pub fn get(
+ acc: &Account,
+ uid: u32,
+ folder: &str,
+ raw: bool,
+ html: bool,
+ mark_read: bool,
+ format: Format,
+) -> Result<()> {
+ let mut s = client::connect(acc)?;
+ if mark_read {
+ s.select(quote_mailbox(folder))?;
+ } else {
+ s.examine(quote_mailbox(folder))?;
+ }
+ let fetches = s.uid_fetch(uid.to_string(), "(UID FLAGS BODY.PEEK[])")?;
+ let f = fetches
+ .iter()
+ .find(|f| f.uid == Some(uid))
+ .ok_or_else(|| anyhow!("no message with UID {uid} in {folder}"))?;
+ let body_bytes = f.body().ok_or_else(|| anyhow!("server returned no body"))?;
+ if raw {
+ std::io::stdout().write_all(body_bytes)?;
+ let _ = s.logout();
+ return Ok(());
+ }
+ let mail = mailparse::parse_mail(body_bytes)?;
+ let h = crate::message::parse_header_summary(body_bytes);
+ let out = json!({
+ "uid": uid,
+ "flags": f.flags().iter().map(flag_to_string).collect::<Vec<_>>(),
+ "date": h.date,
+ "from": h.from,
+ "to": h.to,
+ "subject": h.subject,
+ "body": extract_body(&mail, html),
+ });
+ if mark_read {
+ s.uid_store(uid.to_string(), "+FLAGS (\\Seen)")?;
+ }
+ let _ = s.logout();
+ match format {
+ Format::Json => print_json(&out),
+ Format::Table => {
+ for k in ["date", "from", "to", "subject"] {
+ if let Some(v) = out[k].as_str() {
+ println!("{}: {}", k.to_uppercase(), v);
+ }
+ }
+ println!();
+ println!("{}", out["body"].as_str().unwrap_or("(no text body)"));
+ Ok(())
+ }
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+pub fn send(
+ acc: &Account,
+ to: &[String],
+ cc: &[String],
+ subject: &str,
+ body: Option<String>,
+ body_file: Option<PathBuf>,
+ no_record: bool,
+) -> Result<()> {
+ use lettre::transport::smtp::authentication::Credentials;
+ use lettre::{Message, SmtpTransport, Transport};
+
+ let (ep, pass) = acc.smtp()?;
+ let from_mbox = acc.sender_mailbox()?;
+
+ let body_text = match (body, body_file) {
+ (Some(b), _) => b,
+ (None, Some(p)) => {
+ std::fs::read_to_string(&p).with_context(|| format!("cannot read {}", p.display()))?
+ }
+ (None, None) => {
+ let mut buf = String::new();
+ std::io::stdin()
+ .read_to_string(&mut buf)
+ .context("reading body from stdin")?;
+ buf
+ }
+ };
+
+ let mut builder = Message::builder()
+ .from(from_mbox.parse().context("invalid From address")?)
+ .subject(subject);
+ for t in to {
+ builder = builder.to(t
+ .parse()
+ .with_context(|| format!("invalid recipient: {t}"))?);
+ }
+ for c in cc {
+ builder = builder.cc(c.parse().with_context(|| format!("invalid cc: {c}"))?);
+ }
+ let email = builder.body(body_text)?;
+
+ let creds = Credentials::new(ep.user.clone(), pass);
+ let mailer = if ep.implicit_tls {
+ SmtpTransport::relay(&ep.host)?
+ } else {
+ SmtpTransport::starttls_relay(&ep.host)?
+ }
+ .port(ep.port)
+ .credentials(creds)
+ .build();
+ mailer.send(&email).context("SMTP send failed")?;
+
+ let mut recorded = None;
+ if !no_record && acc.imap.is_some() {
+ let mut s = client::connect(acc)?;
+ s.append(quote_mailbox(&acc.sent_folder), email.formatted())?;
+ let _ = s.logout();
+ recorded = Some(acc.sent_folder.clone());
+ }
+ print_json(&json!({
+ "status": "sent",
+ "from": from_mbox,
+ "to": to,
+ "cc": cc,
+ "subject": subject,
+ "record": recorded,
+ }))
+}
+
+pub fn mark(acc: &Account, state: MarkState, uids: &[u32], folder: &str) -> Result<()> {
+ let flags = match state {
+ MarkState::Read => "+FLAGS (\\Seen)",
+ MarkState::Unread => "-FLAGS (\\Seen)",
+ MarkState::Flagged => "+FLAGS (\\Flagged)",
+ MarkState::Unflagged => "-FLAGS (\\Flagged)",
+ };
+ let mut s = client::connect(acc)?;
+ s.select(quote_mailbox(folder))?;
+ s.uid_store(uid_set(uids), flags)?;
+ let _ = s.logout();
+ print_json(&json!({ "status": "ok", "folder": folder, "uids": uids, "flags": flags }))
+}
+
+pub fn move_cmd(acc: &Account, uids: &[u32], to: &str, folder: &str) -> Result<()> {
+ let mut s = client::connect(acc)?;
+ s.select(quote_mailbox(folder))?;
+ client::move_uids(&mut s, uids, to)?;
+ let _ = s.logout();
+ print_json(&json!({ "status": "moved", "from": folder, "to": to, "uids": uids }))
+}
+
+pub fn search(acc: &Account, query: &str, folder: &str, format: Format) -> Result<()> {
+ let mut s = client::connect(acc)?;
+ s.examine(quote_mailbox(folder))?;
+ let uids = sorted_desc(s.uid_search(query)?);
+ let _ = s.logout();
+ match format {
+ Format::Json => print_json(&json!({
+ "folder": folder,
+ "query": query,
+ "count": uids.len(),
+ "uids": uids,
+ })),
+ Format::Table => {
+ for u in uids {
+ println!("{u}");
+ }
+ Ok(())
+ }
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..880685b
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,453 @@
+//! Configuration: a TOML file with named accounts, plus environment overrides.
+//!
+//! Resolution order (later wins):
+//! 1. Config file: `--config`, `$POSTA_CONFIG`, or `~/.config/posta/config.toml`.
+//! 2. Account picked with `--account`, `$POSTA_ACCOUNT`, `default_account`,
+//! or the only account defined.
+//! 3. `POSTA_*` environment variables override individual fields (and allow
+//! running with no config file at all).
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use anyhow::{anyhow, bail, Context, Result};
+use serde::Deserialize;
+
+use crate::mailurl::{parse_mail_url, Endpoint};
+
+pub const DEFAULT_CONFIG_PATH: &str = "~/.config/posta/config.toml";
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ConfigFile {
+ /// Account used when `--account` / `$POSTA_ACCOUNT` are not given.
+ pub default_account: Option<String>,
+ #[serde(default)]
+ pub accounts: BTreeMap<String, AccountConfig>,
+}
+
+#[derive(Debug, Default, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AccountConfig {
+ /// Address used in `From:` when sending.
+ pub email: Option<String>,
+ /// Human name for the `From:` header.
+ pub display_name: Option<String>,
+ /// `imap://user@host[:port]` (STARTTLS) or `imaps://` (implicit TLS).
+ pub imap_url: Option<String>,
+ /// `smtp://user@host[:port]` (STARTTLS) or `smtps://` (implicit TLS).
+ pub smtp_url: Option<String>,
+ /// Shared password for both protocols. Prefer `password_cmd`.
+ pub password: Option<String>,
+ /// Command whose first stdout line is the password (e.g. `pass show mail`).
+ pub password_cmd: Option<String>,
+ pub imap_password: Option<String>,
+ pub imap_password_cmd: Option<String>,
+ pub smtp_password: Option<String>,
+ pub smtp_password_cmd: Option<String>,
+ /// Where sent mail is appended (default: "Sent").
+ pub sent_folder: Option<String>,
+ /// Where `delete` moves messages (default: "Trash").
+ pub trash_folder: Option<String>,
+}
+
+/// A password that may be a literal or a command to run.
+#[derive(Debug, Clone)]
+pub enum Secret {
+ Literal(String),
+ Command(String),
+}
+
+impl Secret {
+ pub fn resolve(&self) -> Result<String> {
+ match self {
+ Secret::Literal(s) => Ok(s.clone()),
+ Secret::Command(cmd) => {
+ let out = Command::new("sh")
+ .arg("-c")
+ .arg(cmd)
+ .output()
+ .with_context(|| format!("failed to run password_cmd: {cmd}"))?;
+ if !out.status.success() {
+ bail!(
+ "password_cmd exited with {}: {}",
+ out.status,
+ String::from_utf8_lossy(&out.stderr).trim()
+ );
+ }
+ let pass = String::from_utf8(out.stdout)
+ .context("password_cmd printed non-UTF-8 output")?;
+ let pass = pass.lines().next().unwrap_or("").to_string();
+ if pass.is_empty() {
+ bail!("password_cmd printed nothing: {cmd}");
+ }
+ Ok(pass)
+ }
+ }
+ }
+}
+
+/// A fully resolved account, ready to connect with.
+#[derive(Debug, Clone)]
+pub struct Account {
+ pub name: String,
+ pub email: Option<String>,
+ pub display_name: Option<String>,
+ pub imap: Option<Endpoint>,
+ pub smtp: Option<Endpoint>,
+ imap_secret: Option<Secret>,
+ smtp_secret: Option<Secret>,
+ pub sent_folder: String,
+ pub trash_folder: String,
+}
+
+impl Account {
+ pub fn imap(&self) -> Result<(&Endpoint, String)> {
+ let ep = self.imap.as_ref().ok_or_else(|| {
+ anyhow!("no IMAP endpoint configured (set `imap_url` or $POSTA_IMAP_URL)")
+ })?;
+ let pass = self
+ .imap_secret
+ .as_ref()
+ .ok_or_else(|| {
+ anyhow!("no IMAP password configured (set `password`, `password_cmd` or $POSTA_PASSWORD)")
+ })?
+ .resolve()?;
+ Ok((ep, pass))
+ }
+
+ pub fn smtp(&self) -> Result<(&Endpoint, String)> {
+ let ep = self.smtp.as_ref().ok_or_else(|| {
+ anyhow!("no SMTP endpoint configured (set `smtp_url` or $POSTA_SMTP_URL)")
+ })?;
+ let pass = self
+ .smtp_secret
+ .as_ref()
+ .ok_or_else(|| {
+ anyhow!("no SMTP password configured (set `password`, `password_cmd` or $POSTA_PASSWORD)")
+ })?
+ .resolve()?;
+ Ok((ep, pass))
+ }
+
+ /// `Display Name <email>` for the From header.
+ pub fn sender_mailbox(&self) -> Result<String> {
+ let email = self
+ .email
+ .as_deref()
+ .ok_or_else(|| anyhow!("no sender address configured (set `email` or $POSTA_EMAIL)"))?;
+ Ok(match &self.display_name {
+ Some(name) => format!("{name} <{email}>"),
+ None => email.to_string(),
+ })
+ }
+}
+
+fn expand_tilde(p: &str) -> PathBuf {
+ if let Some(rest) = p.strip_prefix("~/") {
+ if let Some(home) = std::env::var_os("HOME") {
+ return PathBuf::from(home).join(rest);
+ }
+ }
+ PathBuf::from(p)
+}
+
+fn env_var(key: &str) -> Option<String> {
+ std::env::var(key).ok().filter(|v| !v.is_empty())
+}
+
+/// Locates the config file: explicit flag > $POSTA_CONFIG > default path (if present).
+pub fn locate(cli_config: &Option<PathBuf>) -> Option<PathBuf> {
+ cli_config
+ .clone()
+ .or_else(|| env_var("POSTA_CONFIG").map(|p| expand_tilde(&p)))
+ .or_else(|| {
+ let d = expand_tilde(DEFAULT_CONFIG_PATH);
+ d.exists().then_some(d)
+ })
+}
+
+pub fn read_config_file(path: &Path) -> Result<ConfigFile> {
+ let text = std::fs::read_to_string(path)
+ .with_context(|| format!("cannot read config file {}", path.display()))?;
+ let cfg: ConfigFile =
+ toml::from_str(&text).with_context(|| format!("invalid config file {}", path.display()))?;
+ warn_if_world_readable(path, &text);
+ Ok(cfg)
+}
+
+/// A config file holding literal passwords should not be readable by others.
+fn warn_if_world_readable(path: &Path, text: &str) {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let has_literal_password = text.lines().any(|l| {
+ let l = l.trim_start();
+ l.starts_with("password") && !l.starts_with("password_cmd")
+ || l.starts_with("imap_password ")
+ || l.starts_with("imap_password=")
+ || l.starts_with("smtp_password ")
+ || l.starts_with("smtp_password=")
+ });
+ if let Ok(meta) = std::fs::metadata(path) {
+ if has_literal_password && meta.permissions().mode() & 0o077 != 0 {
+ eprintln!(
+ "warning: {} contains passwords but is readable by other users; \
+ run: chmod 600 {}",
+ path.display(),
+ path.display()
+ );
+ }
+ }
+ }
+ #[cfg(not(unix))]
+ {
+ let _ = (path, text);
+ }
+}
+
+fn pick_account(cfg: &ConfigFile, requested: &Option<String>) -> Result<(String, AccountConfig)> {
+ let requested = requested.clone().or_else(|| env_var("POSTA_ACCOUNT"));
+ let name = match requested {
+ Some(n) => n,
+ None => match (&cfg.default_account, cfg.accounts.len()) {
+ (Some(d), _) => d.clone(),
+ (None, 1) => cfg.accounts.keys().next().unwrap().clone(),
+ (None, 0) => bail!("the config file defines no accounts"),
+ (None, _) => bail!(
+ "several accounts defined and no default; pass --account or set \
+ `default_account`. Available: {}",
+ cfg.accounts.keys().cloned().collect::<Vec<_>>().join(", ")
+ ),
+ },
+ };
+ let acc = cfg.accounts.get(&name).ok_or_else(|| {
+ anyhow!(
+ "account '{}' not found in config. Available: {}",
+ name,
+ cfg.accounts.keys().cloned().collect::<Vec<_>>().join(", ")
+ )
+ })?;
+ Ok((name, acc.clone()))
+}
+
+fn secret_for(
+ specific_literal: &Option<String>,
+ specific_cmd: &Option<String>,
+ shared_literal: &Option<String>,
+ shared_cmd: &Option<String>,
+) -> Option<Secret> {
+ specific_literal
+ .clone()
+ .map(Secret::Literal)
+ .or_else(|| specific_cmd.clone().map(Secret::Command))
+ .or_else(|| shared_literal.clone().map(Secret::Literal))
+ .or_else(|| shared_cmd.clone().map(Secret::Command))
+}
+
+/// Loads and resolves the account to operate on.
+pub fn load(cli_config: &Option<PathBuf>, cli_account: &Option<String>) -> Result<Account> {
+ let (name, mut raw) = match locate(cli_config) {
+ Some(path) => {
+ let cfg = read_config_file(&path)?;
+ pick_account(&cfg, cli_account)?
+ }
+ None => ("env".to_string(), AccountConfig::default()),
+ };
+
+ // Environment overrides individual fields of the selected account.
+ if let Some(v) = env_var("POSTA_EMAIL") {
+ raw.email = Some(v);
+ }
+ if let Some(v) = env_var("POSTA_DISPLAY_NAME") {
+ raw.display_name = Some(v);
+ }
+ if let Some(v) = env_var("POSTA_IMAP_URL") {
+ raw.imap_url = Some(v);
+ }
+ if let Some(v) = env_var("POSTA_SMTP_URL") {
+ raw.smtp_url = Some(v);
+ }
+ if let Some(v) = env_var("POSTA_PASSWORD") {
+ raw.password = Some(v);
+ raw.imap_password = None;
+ raw.smtp_password = None;
+ }
+ if let Some(v) = env_var("POSTA_PASSWORD_CMD") {
+ raw.password_cmd = Some(v);
+ }
+ if let Some(v) = env_var("POSTA_IMAP_PASSWORD") {
+ raw.imap_password = Some(v);
+ }
+ if let Some(v) = env_var("POSTA_SMTP_PASSWORD") {
+ raw.smtp_password = Some(v);
+ }
+ if let Some(v) = env_var("POSTA_SENT_FOLDER") {
+ raw.sent_folder = Some(v);
+ }
+ if let Some(v) = env_var("POSTA_TRASH_FOLDER") {
+ raw.trash_folder = Some(v);
+ }
+
+ let imap = raw.imap_url.as_deref().map(parse_mail_url).transpose()?;
+ let smtp = raw.smtp_url.as_deref().map(parse_mail_url).transpose()?;
+ // With no explicit email, fall back to an IMAP login that looks like an address.
+ let email = raw.email.clone().or_else(|| {
+ imap.as_ref()
+ .map(|e| e.user.clone())
+ .filter(|u| u.contains('@'))
+ });
+
+ Ok(Account {
+ name,
+ email,
+ display_name: raw.display_name.clone(),
+ imap,
+ smtp,
+ imap_secret: secret_for(
+ &raw.imap_password,
+ &raw.imap_password_cmd,
+ &raw.password,
+ &raw.password_cmd,
+ ),
+ smtp_secret: secret_for(
+ &raw.smtp_password,
+ &raw.smtp_password_cmd,
+ &raw.password,
+ &raw.password_cmd,
+ ),
+ sent_folder: raw.sent_folder.unwrap_or_else(|| "Sent".to_string()),
+ trash_folder: raw.trash_folder.unwrap_or_else(|| "Trash".to_string()),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+
+ const SAMPLE: &str = r#"
+default_account = "personal"
+
+[accounts.personal]
+email = "alice@example.com"
+display_name = "Alice"
+imap_url = "imap://alice@example.com@mail.example.com:143"
+smtp_url = "smtp://alice@example.com@mail.example.com:587"
+password = "hunter2"
+sent_folder = "Sent"
+trash_folder = "Trash"
+
+[accounts.work]
+email = "alice@work.example"
+imap_url = "imaps://alice@imap.work.example"
+smtp_url = "smtps://alice@smtp.work.example"
+password_cmd = "echo s3cret"
+"#;
+
+ fn write_sample() -> tempfile::NamedTempFile {
+ let mut f = tempfile::NamedTempFile::new().unwrap();
+ f.write_all(SAMPLE.as_bytes()).unwrap();
+ f
+ }
+
+ #[test]
+ fn parses_accounts_and_default() {
+ let cfg: ConfigFile = toml::from_str(SAMPLE).unwrap();
+ assert_eq!(cfg.default_account.as_deref(), Some("personal"));
+ assert_eq!(cfg.accounts.len(), 2);
+ assert_eq!(
+ cfg.accounts["personal"].email.as_deref(),
+ Some("alice@example.com")
+ );
+ }
+
+ #[test]
+ fn rejects_unknown_keys() {
+ let bad = "[accounts.a]\npasword = \"typo\"\n";
+ assert!(toml::from_str::<ConfigFile>(bad).is_err());
+ }
+
+ #[test]
+ fn picks_default_account() {
+ let cfg: ConfigFile = toml::from_str(SAMPLE).unwrap();
+ let (name, acc) = pick_account(&cfg, &None).unwrap();
+ assert_eq!(name, "personal");
+ assert_eq!(acc.display_name.as_deref(), Some("Alice"));
+ }
+
+ #[test]
+ fn picks_requested_account() {
+ let cfg: ConfigFile = toml::from_str(SAMPLE).unwrap();
+ let (name, acc) = pick_account(&cfg, &Some("work".into())).unwrap();
+ assert_eq!(name, "work");
+ assert!(acc.password_cmd.is_some());
+ }
+
+ #[test]
+ fn unknown_account_lists_available() {
+ let cfg: ConfigFile = toml::from_str(SAMPLE).unwrap();
+ let err = pick_account(&cfg, &Some("nope".into()))
+ .unwrap_err()
+ .to_string();
+ assert!(
+ err.contains("personal") && err.contains("work"),
+ "err: {err}"
+ );
+ }
+
+ #[test]
+ fn single_account_needs_no_default() {
+ let cfg: ConfigFile = toml::from_str("[accounts.only]\nemail = \"a@b.c\"\n").unwrap();
+ let (name, _) = pick_account(&cfg, &None).unwrap();
+ assert_eq!(name, "only");
+ }
+
+ #[test]
+ fn several_accounts_without_default_is_an_error() {
+ let cfg: ConfigFile = toml::from_str("[accounts.a]\n[accounts.b]\n").unwrap();
+ assert!(pick_account(&cfg, &None).is_err());
+ }
+
+ #[test]
+ fn load_resolves_endpoints_and_secret() {
+ let f = write_sample();
+ let acc = load(&Some(f.path().to_path_buf()), &Some("personal".into())).unwrap();
+ assert_eq!(acc.imap.as_ref().unwrap().host, "mail.example.com");
+ assert!(!acc.imap.as_ref().unwrap().implicit_tls);
+ assert_eq!(acc.smtp.as_ref().unwrap().port, 587);
+ let (_, pass) = acc.imap().unwrap();
+ assert_eq!(pass, "hunter2");
+ assert_eq!(acc.sender_mailbox().unwrap(), "Alice <alice@example.com>");
+ assert_eq!(acc.sent_folder, "Sent");
+ assert_eq!(acc.trash_folder, "Trash");
+ }
+
+ #[test]
+ fn password_cmd_runs_and_takes_first_line() {
+ let f = write_sample();
+ let acc = load(&Some(f.path().to_path_buf()), &Some("work".into())).unwrap();
+ let (_, pass) = acc.imap().unwrap();
+ assert_eq!(pass, "s3cret");
+ }
+
+ #[test]
+ fn failing_password_cmd_is_an_error() {
+ assert!(Secret::Command("exit 3".into()).resolve().is_err());
+ assert!(Secret::Command("true".into()).resolve().is_err()); // prints nothing
+ }
+
+ #[test]
+ fn email_falls_back_to_imap_login() {
+ let toml_src = r#"
+[accounts.a]
+imap_url = "imap://me@example.com@mail.example.com"
+password = "x"
+"#;
+ let mut f = tempfile::NamedTempFile::new().unwrap();
+ f.write_all(toml_src.as_bytes()).unwrap();
+ let acc = load(&Some(f.path().to_path_buf()), &None).unwrap();
+ assert_eq!(acc.email.as_deref(), Some("me@example.com"));
+ }
+}
diff --git a/src/mailurl.rs b/src/mailurl.rs
new file mode 100644
index 0000000..c4285e4
--- /dev/null
+++ b/src/mailurl.rs
@@ -0,0 +1,126 @@
+//! Parsing of `imap[s]://` and `smtp[s]://` endpoint URLs.
+
+use anyhow::{anyhow, bail, Context, Result};
+
+/// A mail server endpoint: who to log in as, where, and how TLS starts.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Endpoint {
+ pub user: String,
+ pub host: String,
+ pub port: u16,
+ /// `true` for TLS-from-the-start (ports 993/465); `false` for STARTTLS.
+ pub implicit_tls: bool,
+}
+
+/// Parses `imap[s]://user@host[:port]` or `smtp[s]://user@host[:port]`.
+///
+/// The user part may itself contain `@` (e.g. `imap://me@example.com@