330 lines
12 KiB
Rust
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,
|
|
}),
|
|
})
|
|
}
|