tuimail/src/setup.rs
2026-03-02 16:41:19 +01:00

330 lines
12 KiB
Rust

use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use crate::config::{BridgeConfig, Config, ImapConfig, Provider, ProtonConfig, SmtpConfig, TlsMode};
use crate::credentials::{self, CredentialStore};
// ── helpers ──────────────────────────────────────────────────────────────────
fn prompt(label: &str, default: Option<&str>) -> String {
let hint = default.map(|d| format!(" [{d}]")).unwrap_or_default();
print!("{label}{hint}: ");
io::stdout().flush().unwrap();
let mut line = String::new();
io::stdin().lock().read_line(&mut line).unwrap();
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
default.unwrap_or("").to_string()
} else {
trimmed
}
}
/// Optional password prompt — returns None (keep existing) when Enter is pressed with no input.
/// Use for --configure where a stored value may already exist.
fn prompt_password_optional(label: &str) -> Option<String> {
print!("{label} [stored, Enter to keep]: ");
io::stdout().flush().unwrap();
let mut line = String::new();
io::stdin().lock().read_line(&mut line).unwrap();
let trimmed = line.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
}
/// Required password prompt — keeps asking until the user types something.
fn prompt_password_required(label: &str) -> String {
loop {
print!("{label}: ");
io::stdout().flush().unwrap();
let mut line = String::new();
io::stdin().lock().read_line(&mut line).unwrap();
let trimmed = line.trim().to_string();
if !trimmed.is_empty() {
return trimmed;
}
println!("Password cannot be empty.");
}
}
fn prompt_bool(label: &str, default: bool) -> bool {
let hint = if default { "Y/n" } else { "y/N" };
print!("{label} [{hint}]: ");
io::stdout().flush().unwrap();
let mut line = String::new();
io::stdin().lock().read_line(&mut line).unwrap();
match line.trim().to_lowercase().as_str() {
"y" | "yes" => true,
"n" | "no" => false,
_ => default,
}
}
fn prompt_tls_mode(default: Option<&TlsMode>) -> TlsMode {
let default_str = default.map(|m| match m {
TlsMode::None => "none",
TlsMode::Starttls => "starttls",
TlsMode::Smtps => "smtps",
});
loop {
let val = prompt("SMTP TLS mode (none/starttls/smtps)", default_str);
match val.to_lowercase().as_str() {
"none" => return TlsMode::None,
"starttls" => return TlsMode::Starttls,
"smtps" => return TlsMode::Smtps,
_ => println!("Please enter: none, starttls, or smtps"),
}
}
}
fn random_hex(bytes: usize) -> String {
use rand::RngCore;
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
buf.iter().map(|b| format!("{b:02x}")).collect()
}
fn write_config(config: &Config) -> Result<(), String> {
let path = Config::config_path();
let fallback = PathBuf::from(".");
let dir = path.parent().unwrap_or(&fallback);
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let content = build_toml(config);
std::fs::write(&path, content).map_err(|e| e.to_string())?;
println!("Config written to {}", path.display());
Ok(())
}
fn build_toml(config: &Config) -> String {
let mut out = String::new();
match config.provider {
Provider::Imap => {
// provider line only needed when non-default, but write it for clarity
out.push_str("provider = \"imap\"\n\n");
if let Some(imap) = &config.imap {
out.push_str("[imap]\n");
out.push_str(&format!("host = {:?}\n", imap.host));
out.push_str(&format!("port = {}\n", imap.port));
out.push_str(&format!("username = {:?}\n", imap.username));
out.push_str(&format!("use_tls = {}\n", imap.use_tls));
out.push('\n');
}
if let Some(smtp) = &config.smtp {
out.push_str("[smtp]\n");
out.push_str(&format!("host = {:?}\n", smtp.host));
out.push_str(&format!("port = {}\n", smtp.port));
out.push_str(&format!("username = {:?}\n", smtp.username));
let tls = match &smtp.tls_mode {
TlsMode::None => "none",
TlsMode::Starttls => "starttls",
TlsMode::Smtps => "smtps",
};
out.push_str(&format!("tls_mode = {:?}\n", tls));
out.push_str(&format!("from = {:?}\n", smtp.from));
}
}
Provider::Proton => {
out.push_str("provider = \"proton\"\n\n");
if let Some(proton) = &config.proton {
out.push_str("[proton]\n");
out.push_str(&format!("username = {:?}\n", proton.username));
}
out.push('\n');
if let Some(bridge) = &config.bridge {
out.push_str("[bridge]\n");
out.push_str(&format!("imap_port = {}\n", bridge.imap_port));
out.push_str(&format!("smtp_port = {}\n", bridge.smtp_port));
}
}
}
out
}
// ── public API ───────────────────────────────────────────────────────────────
/// First-time setup wizard. Runs before raw mode on plain stdout.
pub fn run_first_time_setup() -> Result<Config, String> {
println!("=== tuimail first-time setup ===");
println!("Passwords will be stored in the OS keychain.\n");
let provider_str = loop {
let v = prompt("Provider (imap/proton)", Some("imap"));
match v.to_lowercase().as_str() {
"imap" | "proton" => break v.to_lowercase(),
_ => println!("Please enter 'imap' or 'proton'"),
}
};
let config = if provider_str == "imap" {
setup_imap(None)
} else {
setup_proton(None)
}?;
write_config(&config)?;
Ok(config)
}
/// Re-run the wizard with existing values pre-filled (--configure).
pub fn run_configure(existing: &Config) -> Result<(), String> {
println!("=== tuimail configure ===");
println!("Press Enter to keep the current value. Passwords: Enter to keep stored credential.\n");
let config = match existing.provider {
Provider::Imap => setup_imap(Some(existing)),
Provider::Proton => setup_proton(Some(existing)),
}?;
write_config(&config)?;
Ok(())
}
// ── provider-specific flows ───────────────────────────────────────────────────
fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
let ex_imap = existing.and_then(|c| c.imap.as_ref());
let ex_smtp = existing.and_then(|c| c.smtp.as_ref());
// Load once; all existence checks and updates happen in memory.
let mut store = CredentialStore::load();
println!("--- IMAP settings ---");
let imap_host = prompt("IMAP host", ex_imap.map(|i| i.host.as_str()));
let imap_port_default = ex_imap.map(|i| i.port.to_string());
let imap_port: u16 = prompt("IMAP port", imap_port_default.as_deref().or(Some("993")))
.parse()
.unwrap_or(993);
let imap_user = prompt("IMAP username (email)", ex_imap.map(|i| i.username.as_str()));
let imap_tls = prompt_bool("Use TLS for IMAP?", ex_imap.map(|i| i.use_tls).unwrap_or(true));
let imap_pass = if store.contains(credentials::IMAP_PASSWORD) {
prompt_password_optional("IMAP password")
} else {
Some(prompt_password_required("IMAP password"))
};
if let Some(ref pw) = imap_pass {
store.set(credentials::IMAP_PASSWORD, pw);
}
println!("\n--- SMTP settings ---");
let smtp_host = prompt("SMTP host", ex_smtp.map(|s| s.host.as_str()));
let smtp_port_default = ex_smtp.map(|s| s.port.to_string());
let smtp_port: u16 = prompt("SMTP port", smtp_port_default.as_deref().or(Some("465")))
.parse()
.unwrap_or(465);
let smtp_user = prompt("SMTP username (email)", ex_smtp.map(|s| s.username.as_str()));
let smtp_tls = prompt_tls_mode(ex_smtp.map(|s| &s.tls_mode));
let smtp_from = prompt("From address (e.g. Name <addr@example.com>)", ex_smtp.map(|s| s.from.as_str()));
let smtp_pass = if store.contains(credentials::SMTP_PASSWORD) {
prompt_password_optional("SMTP password")
} else {
Some(prompt_password_required("SMTP password"))
};
if let Some(ref pw) = smtp_pass {
store.set(credentials::SMTP_PASSWORD, pw);
}
// One write for all credentials.
store.save()?;
Ok(Config {
provider: Provider::Imap,
imap: Some(ImapConfig {
host: imap_host,
port: imap_port,
username: imap_user,
password: None, // stored in keychain
use_tls: imap_tls,
tls_cert_der: None,
}),
smtp: Some(SmtpConfig {
host: smtp_host,
port: smtp_port,
username: smtp_user,
password: None, // stored in keychain
tls_mode: smtp_tls,
from: smtp_from,
tls_cert_der: None,
}),
proton: None,
bridge: None,
})
}
fn setup_proton(existing: Option<&Config>) -> Result<Config, String> {
let ex_proton = existing.and_then(|c| c.proton.as_ref());
let ex_bridge = existing.and_then(|c| c.bridge.as_ref());
// Load once; all existence checks and updates happen in memory.
let mut store = CredentialStore::load();
println!("--- ProtonMail settings ---");
let username = prompt("Proton account email", ex_proton.map(|p| p.username.as_str()));
let proton_pass = if store.contains(credentials::PROTON_PASSWORD) {
prompt_password_optional("Proton login password")
} else {
Some(prompt_password_required("Proton login password"))
};
if let Some(ref pw) = proton_pass {
store.set(credentials::PROTON_PASSWORD, pw);
}
let two_pw = prompt_bool(
"Use two-password mode?",
ex_proton.and_then(|p| p.mailbox_password.as_ref()).is_some(),
);
if two_pw {
let mbx_pass = if store.contains(credentials::PROTON_MAILBOX_PASSWORD) {
prompt_password_optional("Mailbox password")
} else {
Some(prompt_password_required("Mailbox password"))
};
if let Some(ref pw) = mbx_pass {
store.set(credentials::PROTON_MAILBOX_PASSWORD, pw);
}
} else {
store.delete(credentials::PROTON_MAILBOX_PASSWORD);
}
let bridge_imap_default = ex_bridge.map(|b| b.imap_port.to_string());
let imap_port: u16 = prompt(
"Bridge IMAP port",
bridge_imap_default.as_deref().or(Some("1143")),
)
.parse()
.unwrap_or(1143);
let bridge_smtp_default = ex_bridge.map(|b| b.smtp_port.to_string());
let smtp_port: u16 = prompt(
"Bridge SMTP port",
bridge_smtp_default.as_deref().or(Some("1025")),
)
.parse()
.unwrap_or(1025);
// Generate a new local bridge password only if one isn't already stored.
if !store.contains(credentials::BRIDGE_LOCAL_PASSWORD) {
store.set(credentials::BRIDGE_LOCAL_PASSWORD, &random_hex(16));
println!("Generated bridge local password (stored in keychain).");
}
// One write for all credentials.
store.save()?;
Ok(Config {
provider: Provider::Proton,
imap: None,
smtp: None,
proton: Some(ProtonConfig {
username,
password: None,
mailbox_password: None,
user_key_passphrase: ex_proton.and_then(|p| p.user_key_passphrase.clone()),
}),
bridge: Some(BridgeConfig {
imap_port,
smtp_port,
local_password: None,
}),
})
}