From 2287b08cb59a61745c73e5dd4730e07494a3c128 Mon Sep 17 00:00:00 2001 From: Shautvast Date: Tue, 24 Feb 2026 22:51:30 +0100 Subject: [PATCH] Integrate proton-bridge in-process and silence verbose logging - Add proton-bridge as optional dep behind `proton` feature flag - New proton-bridge/src/lib.rs: pub fn start() spins a background Tokio thread, pre-binds ports, and signals readiness via mpsc before returning - src/main.rs: conditionally starts bridge before TUI enters raw mode; derives effective IMAP/SMTP config via Provider enum - src/config.rs: add Provider enum, optional imap/smtp, ProtonConfig/ BridgeConfig mirrors, effective_imap/smtp() helpers - Remove all per-operation eprintln!/println! from imap_server, smtp_server, and api.rs that fired during TUI operation and corrupted the display - config.toml.example: unified format covering both imap and proton providers Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 +- Cargo.lock | 1 + Cargo.toml | 4 + USAGE.md | 40 ++++- config.toml.example | 48 ++++-- proton-bridge/src/api.rs | 12 -- proton-bridge/src/config.rs | 7 - proton-bridge/src/imap_server.rs | 19 +- proton-bridge/src/lib.rs | 262 ++++++++++++++++++++++++++++ proton-bridge/src/main.rs | 288 +++---------------------------- proton-bridge/src/smtp_server.rs | 54 ++---- src/config.rs | 115 +++++++++++- src/connect.rs | 2 +- src/lib.rs | 2 +- src/main.rs | 54 +++++- 15 files changed, 548 insertions(+), 362 deletions(-) create mode 100644 proton-bridge/src/lib.rs diff --git a/.gitignore b/.gitignore index 7d90bf6..4b65797 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /target docker-data/ -config.toml +config.toml.old .idea/ bridge.toml session.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index fed3660..80425b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4212,6 +4212,7 @@ dependencies = [ "lettre", "mailparse", "native-tls", + "proton-bridge", "quoted_printable", "ratatui", "regex", diff --git a/Cargo.toml b/Cargo.toml index 761a5e5..54cb928 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,11 @@ name = "tuimail" version = "0.1.0" edition = "2024" +[features] +proton = ["dep:proton-bridge"] + [dependencies] +proton-bridge = { path = "proton-bridge", optional = true } ratatui = "0.30" crossterm = "0.29" imap = "2.4" diff --git a/USAGE.md b/USAGE.md index c64a9bc..855215b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -38,14 +38,50 @@ from = "Your Name " |----------|-----------|-----------|---------|-----------|-----------|----------| | Gmail | imap.gmail.com | 993 | true | smtp.gmail.com | 465 | smtps | | Outlook/Hotmail | outlook.office365.com | 993 | true | smtp.office365.com | 587 | starttls | -| ProtonMail (Bridge) | 127.0.0.1 | 1143 | false | 127.0.0.1 | 1025 | none | +| ProtonMail | see ProtonMail section below ||||| | | Local test server | localhost | 143 | false | localhost | 25 | none | > **Gmail note:** You must use an [App Password](https://myaccount.google.com/apppasswords), > not your regular password. Enable 2-Step Verification first, then generate an > App Password for "Mail". -Then run: +### ProtonMail + +tuimail can talk to ProtonMail directly — no separate bridge process needed. +The bridge starts automatically in-process when `provider = "proton"` is set. + +**1. Build with ProtonMail support:** + +```bash +cargo build --features proton +``` + +**2. Configure `config.toml`** (remove or comment out `[imap]` / `[smtp]`): + +```toml +provider = "proton" + +[proton] +username = "you@proton.me" +password = "your-proton-login-password" +# mailbox_password = "..." # only for two-password-mode accounts + +[bridge] +imap_port = 1143 +smtp_port = 1025 +local_password = "changeme" # any string; used only locally +``` + +**3. Run:** + +```bash +cargo run --features proton +``` + +The bridge authenticates with ProtonMail before the TUI opens. Messages are +decrypted on the fly; sent mail is encrypted end-to-end automatically. + +Then run (standard providers): ```bash cargo run --release diff --git a/config.toml.example b/config.toml.example index 73112ba..c1a77e6 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1,18 +1,36 @@ +# ── Standard IMAP/SMTP provider (default) ───────────────────────────────────── +# provider = "imap" # optional — "imap" is the default + [imap] -host = "localhost" -port = 143 -username = "test@example.com" -password = "password123" -use_tls = false +host = "imap.gmail.com" # your provider's IMAP server +port = 993 +username = "you@example.com" +password = "your-app-password" +use_tls = true [smtp] -host = "localhost" -port = 25 -username = "test@example.com" -password = "password123" -# tls_mode options: -# none — plain text (port 25 or unencrypted 587) -# starttls — upgrades mid-session (port 587, most providers) -# smtps — TLS from first byte (port 465, Gmail "SSL") -tls_mode = "none" -from = "Test User " \ No newline at end of file +host = "smtp.gmail.com" # your provider's SMTP server +port = 465 +username = "you@example.com" +password = "your-app-password" +# tls_mode options: none | starttls | smtps +tls_mode = "smtps" +from = "Your Name " + + +# ── ProtonMail provider ──────────────────────────────────────────────────────── +# Uncomment the lines below (and remove or comment out [imap]/[smtp] above). +# Build with: cargo build --features proton +# Run with: cargo run --features proton +# +# provider = "proton" +# +# [proton] +# username = "you@proton.me" +# password = "your-proton-login-password" +# # mailbox_password = "..." # only for two-password-mode accounts +# +# [bridge] +# imap_port = 1143 +# smtp_port = 1025 +# local_password = "changeme" # any string; used only locally between tuimail and the bridge diff --git a/proton-bridge/src/api.rs b/proton-bridge/src/api.rs index 95145a1..3ad5081 100644 --- a/proton-bridge/src/api.rs +++ b/proton-bridge/src/api.rs @@ -495,7 +495,6 @@ impl<'a> ApiClient<'a> { } let body = resp.text().await.map_err(|e| e.to_string())?; - println!("key_salts raw: {body}"); let env: Envelope = serde_json::from_str(&body).map_err(|e| format!("key_salts parse: {e}"))?; if env.code != 1000 { @@ -537,13 +536,6 @@ impl<'a> ApiClient<'a> { attachment_key_packets: vec![], action: 0, }; - // Log the full request JSON (body is long, show first 300 chars for structure check). - let req_json = serde_json::to_string(&req).unwrap_or_else(|e| e.to_string()); - eprintln!( - "SMTP create_draft POST /mail/v4/messages AddressID={address_id} to={to_addr}\n\ - [req-json first 300]: {}", - &req_json[..req_json.len().min(300)] - ); let resp = self .client .post(format!("{}/mail/v4/messages", API_BASE)) @@ -628,10 +620,6 @@ impl<'a> ApiClient<'a> { }; // Note: send uses POST to the same path as the draft — no "/send" suffix. // (go-proton-api: SendDraft does POST /{id}, UpdateDraft does PUT /{id}) - eprintln!( - "SMTP send_draft POST /mail/v4/messages/{draft_id}\n body: {}", - serde_json::to_string(&req).unwrap_or_else(|e| e.to_string()) - ); let resp = self .client .post(format!("{}/mail/v4/messages/{}", API_BASE, draft_id)) diff --git a/proton-bridge/src/config.rs b/proton-bridge/src/config.rs index 89c0b1a..a35a1dd 100644 --- a/proton-bridge/src/config.rs +++ b/proton-bridge/src/config.rs @@ -1,5 +1,4 @@ use serde::Deserialize; -use std::fs; #[derive(Debug, Deserialize, Clone)] pub struct Config { @@ -32,9 +31,3 @@ pub struct BridgeConfig { pub local_password: String, } -impl Config { - pub fn load() -> Result> { - let content = fs::read_to_string("bridge.toml")?; - Ok(toml::from_str(&content)?) - } -} \ No newline at end of file diff --git a/proton-bridge/src/imap_server.rs b/proton-bridge/src/imap_server.rs index 0f76615..5d412e7 100644 --- a/proton-bridge/src/imap_server.rs +++ b/proton-bridge/src/imap_server.rs @@ -16,9 +16,17 @@ use crate::{crypto, SharedState}; pub async fn run(state: SharedState, port: u16) -> Result<(), Box> { let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?; println!("IMAP listening on port {port}"); + run_with_listener(state, listener).await +} + +/// Accept connections on an already-bound listener. Used by the embedded bridge +/// so ports are guaranteed to be occupied before `start()` returns. +pub async fn run_with_listener( + state: SharedState, + listener: TcpListener, +) -> Result<(), Box> { loop { - let (socket, addr) = listener.accept().await?; - eprintln!("IMAP: connection from {addr}"); + let (socket, _addr) = listener.accept().await?; let state = Arc::clone(&state); tokio::spawn(async move { if let Err(e) = handle_connection(socket, state).await { @@ -47,7 +55,6 @@ async fn handle_connection( if line.is_empty() { continue; } - eprintln!("IMAP < {line}"); // Every IMAP command is "TAG COMMAND [rest…]" let mut parts = line.splitn(3, ' '); @@ -59,9 +66,9 @@ async fn handle_connection( let response = dispatch(&tag, &cmd, &rest, &state, &mut authenticated).await; - for log_line in response.lines() { - eprintln!("IMAP > {log_line}"); - } + // for log_line in response.lines() { + // eprintln!("IMAP > {log_line}"); + // } writer.write_all(response.as_bytes()).await?; if logout { break; diff --git a/proton-bridge/src/lib.rs b/proton-bridge/src/lib.rs new file mode 100644 index 0000000..d0b5960 --- /dev/null +++ b/proton-bridge/src/lib.rs @@ -0,0 +1,262 @@ +pub mod config; +mod api; +mod auth; +mod crypto; +mod imap_server; +mod smtp_server; +mod srp; +mod store; + +use std::collections::HashMap; +use std::sync::Arc; + +use api::{ApiClient, LABEL_INBOX}; +use store::MessageStore; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +// ── Shared bridge state ─────────────────────────────────────────────────────── + +/// State shared between the IMAP and SMTP server tasks. +pub struct BridgeState { + pub store: MessageStore, + pub key_pool: Vec, + pub http_client: reqwest::Client, + pub session: auth::Session, + pub config: config::Config, + pub sender_addresses: Vec<(String, String)>, + pub own_public_keys: HashMap, + pub sender_key_indices: HashMap, +} + +pub type SharedState = Arc>; + +// ── Public entry point ──────────────────────────────────────────────────────── + +/// Authenticate with ProtonMail, bind IMAP and SMTP ports, then return `Ok(())`. +/// The servers keep running in background Tokio tasks on a dedicated OS thread. +/// Returns `Err` if authentication or port binding fails. +/// +/// This is the integration entry point for tuimail. It is synchronous so the +/// caller (tuimail's main thread) needs no Tokio runtime of its own. +pub fn start(config: config::Config) -> Result<(), String> { + let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + rt.block_on(async move { + match run(config).await { + Ok(()) => { + let _ = ready_tx.send(Ok(())); + // Keep the runtime alive so spawned server tasks keep running. + std::future::pending::<()>().await; + } + Err(e) => { + let _ = ready_tx.send(Err(e)); + } + } + }); + }); + ready_rx + .recv() + .map_err(|_| "bridge thread exited unexpectedly".to_string())? +} + +// ── Async startup ───────────────────────────────────────────────────────────── + +async fn run(config: config::Config) -> Result<(), String> { + let client = auth::build_client().map_err(|e| format!("HTTP client: {e}"))?; + let session = auth::authenticate(&client, &config.proton) + .await + .map_err(|e| format!("Authentication failed: {e}"))?; + eprintln!("Authenticated as {}", config.proton.username); + + let api = ApiClient::new(&client, &session); + + let (key_pool, sender_addresses, own_public_keys, sender_key_indices) = + unlock_key_pool(&api, &config).await?; + eprintln!("{} key(s) in pool", key_pool.len()); + crypto::dump_key_pool(&key_pool); + + let (messages, total) = api + .list_messages(LABEL_INBOX, 0, 50) + .await + .map_err(|e| format!("list_messages: {e}"))?; + let mut store = MessageStore::new(); + store.load_all(messages); + eprintln!("Inbox: {} messages ({total} total)", store.count()); + + // Bind ports before spawning tasks — ports are occupied when start() returns. + let imap_listener = TcpListener::bind(("127.0.0.1", config.bridge.imap_port)) + .await + .map_err(|e| format!("bind IMAP :{}: {e}", config.bridge.imap_port))?; + let smtp_listener = TcpListener::bind(("127.0.0.1", config.bridge.smtp_port)) + .await + .map_err(|e| format!("bind SMTP :{}: {e}", config.bridge.smtp_port))?; + + let state: SharedState = Arc::new(Mutex::new(BridgeState { + store, + key_pool, + http_client: client, + session, + config, + sender_addresses, + own_public_keys, + sender_key_indices, + })); + + let imap_state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(e) = imap_server::run_with_listener(imap_state, imap_listener).await { + eprintln!("IMAP server error: {e}"); + } + }); + tokio::spawn(async move { + if let Err(e) = smtp_server::run_with_listener(state, smtp_listener).await { + eprintln!("SMTP server error: {e}"); + } + }); + + Ok(()) // ← ports are bound; start() unblocks here +} + +// ── Key pool setup ──────────────────────────────────────────────────────────── + +async fn unlock_key_pool( + api: &ApiClient<'_>, + config: &config::Config, +) -> Result< + ( + Vec, + Vec<(String, String)>, + HashMap, + HashMap, + ), + String, +> { + let (user_keys_res, addresses_res, key_salts_res) = tokio::join!( + api.get_user_keys(), + api.get_addresses(), + api.get_key_salts(), + ); + + let user_keys = user_keys_res.map_err(|e| format!("get_user_keys: {e}"))?; + let addresses = addresses_res.map_err(|e| format!("get_addresses: {e}"))?; + + let key_salts = match key_salts_res { + Ok(salts) => salts, + Err(e) => { + if e.contains("403") { + auth::Session::delete(); + return Err(format!( + "Session 'locked' scope expired — session.json deleted. \ + Re-run to enter TOTP and regain 'locked' scope. ({e})" + )); + } + eprintln!("get_key_salts: {e} — continuing without salt"); + vec![] + } + }; + + let password = config + .proton + .mailbox_password + .as_deref() + .unwrap_or(&config.proton.password); + + let passphrase_for = |key_id: &str| -> String { + let salt = key_salts + .iter() + .find(|s| s.id == key_id) + .and_then(|s| s.key_salt.as_deref()) + .unwrap_or(""); + crypto::derive_key_passphrase(password, salt).unwrap_or_else(|_| password.to_string()) + }; + + let mut user_private_keys: Vec = Vec::new(); + for uk in &user_keys { + if uk.active != 1 { + continue; + } + let salt = key_salts + .iter() + .find(|s| s.id == uk.id) + .and_then(|s| s.key_salt.as_deref()) + .unwrap_or(""); + let pp = crypto::derive_key_passphrase(password, salt) + .unwrap_or_else(|_| password.to_string()); + match crypto::PrivateKey::unlock(&uk.private_key, &pp) { + Ok(k) => { + println!("user key {} unlocked", &uk.id[..8.min(uk.id.len())]); + user_private_keys.push(k); + } + Err(e) => eprintln!("user key {} FAILED: {e}", &uk.id[..8.min(uk.id.len())]), + } + } + + let mut key_pool: Vec = Vec::new(); + let mut own_public_keys: HashMap = HashMap::new(); + let mut sender_key_indices: HashMap = HashMap::new(); + for addr in &addresses { + let mut first_active_key_done = false; + for ak in &addr.keys { + if ak.active != 1 { + continue; + } + let pp = if let Some(token) = &ak.token { + let mut token_pp = None; + for uk in &user_private_keys { + if let Ok(p) = crypto::decrypt_token(token, uk) { + token_pp = Some(p); + break; + } + } + token_pp.unwrap_or_else(|| passphrase_for(&ak.id)) + } else { + passphrase_for(&ak.id) + }; + match crypto::PrivateKey::unlock(&ak.private_key, &pp) { + Ok(k) => { + println!( + "address key {} ({}) unlocked primary={}", + &ak.id[..8.min(ak.id.len())], + addr.email, + ak.primary + ); + if !first_active_key_done { + match k.public_key_armored() { + Ok(pk) => { + eprintln!( + " [own-key] {} → first-active key primary={}", + addr.email, ak.primary + ); + own_public_keys.insert(addr.email.clone(), pk); + sender_key_indices.insert(addr.email.clone(), key_pool.len()); + } + Err(e) => eprintln!("extract pub key for {}: {e}", addr.email), + } + first_active_key_done = true; + } + key_pool.push(k); + } + Err(e) => eprintln!( + "address key {} ({}): {e}", + &ak.id[..8.min(ak.id.len())], + addr.email + ), + } + } + } + + key_pool.extend(user_private_keys); + + let sender_addresses: Vec<(String, String)> = addresses + .iter() + .map(|a| (a.id.clone(), a.email.clone())) + .collect(); + + println!("{} own public key(s) from address keys", own_public_keys.len()); + Ok((key_pool, sender_addresses, own_public_keys, sender_key_indices)) +} diff --git a/proton-bridge/src/main.rs b/proton-bridge/src/main.rs index 5df5f0a..e6d8604 100644 --- a/proton-bridge/src/main.rs +++ b/proton-bridge/src/main.rs @@ -1,276 +1,32 @@ -mod api; -mod auth; -mod config; -mod crypto; -mod imap_server; -mod smtp_server; -mod srp; -mod store; +use std::fs; -use std::collections::HashMap; -use std::sync::Arc; - -use api::{ApiClient, LABEL_INBOX}; -use store::MessageStore; -use tokio::sync::Mutex; - -// ── Shared bridge state ─────────────────────────────────────────────────────── - -/// State shared between the IMAP and SMTP server tasks. -pub struct BridgeState { - pub store: MessageStore, - pub key_pool: Vec, - /// The underlying reqwest client (stored so we can create ApiClient on demand). - pub http_client: reqwest::Client, - pub session: auth::Session, - pub config: config::Config, - /// ProtonMail address ID → email pairs (for `AddressID` in draft creation). - pub sender_addresses: Vec<(String, String)>, - /// email → armored public key for draft self-encryption. - /// Uses the first active key per address (matches addrKR.FirstKey() in go-proton-api). - pub own_public_keys: HashMap, - /// email → index in `key_pool` of the signing key for that address. - /// The signing key is the same first-active address key used for `own_public_keys`. - pub sender_key_indices: HashMap, -} - -pub type SharedState = Arc>; - -// ── Entry point ─────────────────────────────────────────────────────────────── - -#[tokio::main] -async fn main() { +fn main() { env_logger::init(); - let config = match config::Config::load() { - Ok(c) => c, - Err(e) => { - eprintln!("Failed to load bridge.toml: {e}"); - std::process::exit(1); - } - }; + let content = fs::read_to_string("config.toml").unwrap_or_else(|e| { + eprintln!("Failed to read config.toml: {e}"); + std::process::exit(1); + }); + let config: proton_bridge::config::Config = toml::from_str(&content).unwrap_or_else(|e| { + eprintln!("Failed to parse config.toml: {e}"); + std::process::exit(1); + }); - let client = match auth::build_client() { - Ok(c) => c, - Err(e) => { - eprintln!("Failed to build HTTP client: {e}"); - std::process::exit(1); - } - }; - - let session = match auth::authenticate(&client, &config.proton).await { - Ok(s) => s, - Err(e) => { - eprintln!("Authentication failed: {e}"); - std::process::exit(1); - } - }; - println!("Authenticated as {}", config.proton.username); - - let api = ApiClient::new(&client, &session); - - // Unlock user + address keys, collect address ID/email pairs, and extract - // own public keys directly from address private keys for draft self-encryption. - let (key_pool, sender_addresses, own_public_keys, sender_key_indices) = - match unlock_key_pool(&api, &config).await { - Ok(k) => k, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - println!("{} key(s) in pool", key_pool.len()); - crypto::dump_key_pool(&key_pool); - - // Load the initial inbox into the message store. - let (messages, total) = match api.list_messages(LABEL_INBOX, 0, 50).await { - Ok(r) => r, - Err(e) => { - eprintln!("list_messages failed: {e}"); - std::process::exit(1); - } - }; - let mut store = MessageStore::new(); - store.load_all(messages); - println!("Inbox: {} messages ({total} total)", store.count()); - - let state: SharedState = Arc::new(Mutex::new(BridgeState { - store, - key_pool, - http_client: client, - session, - config: config.clone(), - sender_addresses, - own_public_keys, - sender_key_indices, - })); - - // Spawn IMAP server. - let imap_state = Arc::clone(&state); let imap_port = config.bridge.imap_port; - tokio::spawn(async move { - if let Err(e) = imap_server::run(imap_state, imap_port).await { - eprintln!("IMAP server error: {e}"); - } - }); - - // Spawn SMTP server. - let smtp_state = Arc::clone(&state); let smtp_port = config.bridge.smtp_port; - tokio::spawn(async move { - if let Err(e) = smtp_server::run(smtp_state, smtp_port).await { - eprintln!("SMTP server error: {e}"); - } + + eprint!("Starting ProtonMail bridge..."); + proton_bridge::start(config).unwrap_or_else(|e| { + eprintln!("\nBridge failed to start: {e}"); + std::process::exit(1); }); + eprintln!(" ready. IMAP :{imap_port} SMTP :{smtp_port} (Ctrl-C to stop)"); - println!( - "Bridge ready — IMAP :{}, SMTP :{} (Ctrl-C to stop)", - config.bridge.imap_port, config.bridge.smtp_port - ); - - tokio::signal::ctrl_c() - .await - .expect("failed to listen for ctrl-c"); + // Block until Ctrl-C (the servers run in a background thread). + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + rt.block_on(tokio::signal::ctrl_c()).ok(); println!("Shutting down."); } - -// ── Key pool setup ──────────────────────────────────────────────────────────── - -async fn unlock_key_pool( - api: &ApiClient<'_>, - config: &config::Config, -) -> Result<(Vec, Vec<(String, String)>, HashMap, HashMap), String> { - // Fetch user keys, address keys, and bcrypt salts in parallel. - let (user_keys_res, addresses_res, key_salts_res) = tokio::join!( - api.get_user_keys(), - api.get_addresses(), - api.get_key_salts(), - ); - - let user_keys = user_keys_res.map_err(|e| format!("get_user_keys: {e}"))?; - let addresses = addresses_res.map_err(|e| format!("get_addresses: {e}"))?; - - let key_salts = match key_salts_res { - Ok(salts) => salts, - Err(e) => { - if e.contains("403") { - auth::Session::delete(); - return Err(format!( - "Session 'locked' scope expired — session.json deleted. \ - Re-run to enter TOTP and regain 'locked' scope. ({e})" - )); - } - eprintln!("get_key_salts: {e} — continuing without salt"); - vec![] - } - }; - - let password = config - .proton - .mailbox_password - .as_deref() - .unwrap_or(&config.proton.password); - - // Derive the passphrase for a key (bcrypt-31 or raw password fallback). - let passphrase_for = |key_id: &str| -> String { - let salt = key_salts - .iter() - .find(|s| s.id == key_id) - .and_then(|s| s.key_salt.as_deref()) - .unwrap_or(""); - crypto::derive_key_passphrase(password, salt).unwrap_or_else(|_| password.to_string()) - }; - - // Phase 1: unlock user keys. - let mut user_private_keys: Vec = Vec::new(); - for uk in &user_keys { - if uk.active != 1 { - continue; - } - let salt = key_salts - .iter() - .find(|s| s.id == uk.id) - .and_then(|s| s.key_salt.as_deref()) - .unwrap_or(""); - let pp = crypto::derive_key_passphrase(password, salt) - .unwrap_or_else(|_| password.to_string()); - match crypto::PrivateKey::unlock(&uk.private_key, &pp) { - Ok(k) => { - println!("user key {} unlocked", &uk.id[..8.min(uk.id.len())]); - user_private_keys.push(k); - } - Err(e) => eprintln!("user key {} FAILED: {e}", &uk.id[..8.min(uk.id.len())]), - } - } - - // Phase 2: unlock address keys via token (newer accounts) or shared passphrase. - // Extract the FIRST active key per address as the own public key for draft - // self-encryption — this matches addrKR.FirstKey() used by go-proton-api. - let mut key_pool: Vec = Vec::new(); - let mut own_public_keys: HashMap = HashMap::new(); - let mut sender_key_indices: HashMap = HashMap::new(); - for addr in &addresses { - let mut first_active_key_done = false; - for ak in &addr.keys { - if ak.active != 1 { - continue; - } - let pp = if let Some(token) = &ak.token { - let mut token_pp = None; - for uk in &user_private_keys { - if let Ok(p) = crypto::decrypt_token(token, uk) { - token_pp = Some(p); - break; - } - } - token_pp.unwrap_or_else(|| passphrase_for(&ak.id)) - } else { - passphrase_for(&ak.id) - }; - match crypto::PrivateKey::unlock(&ak.private_key, &pp) { - Ok(k) => { - println!( - "address key {} ({}) unlocked primary={}", - &ak.id[..8.min(ak.id.len())], - addr.email, - ak.primary - ); - // Use the first active key per address (matches addrKR.FirstKey()). - if !first_active_key_done { - match k.public_key_armored() { - Ok(pk) => { - eprintln!( - " [own-key] {} → first-active key primary={}", - addr.email, ak.primary - ); - own_public_keys.insert(addr.email.clone(), pk); - // Record which key_pool index to use for signing. - sender_key_indices.insert(addr.email.clone(), key_pool.len()); - } - Err(e) => eprintln!("extract pub key for {}: {e}", addr.email), - } - first_active_key_done = true; - } - key_pool.push(k); - } - Err(e) => eprintln!( - "address key {} ({}): {e}", - &ak.id[..8.min(ak.id.len())], - addr.email - ), - } - } - } - - // Include user keys as fallback. - key_pool.extend(user_private_keys); - - // Collect (address_id, email) pairs for use when creating drafts. - let sender_addresses: Vec<(String, String)> = addresses - .iter() - .map(|a| (a.id.clone(), a.email.clone())) - .collect(); - - println!("{} own public key(s) from address keys", own_public_keys.len()); - Ok((key_pool, sender_addresses, own_public_keys, sender_key_indices)) -} diff --git a/proton-bridge/src/smtp_server.rs b/proton-bridge/src/smtp_server.rs index c05d76e..50f45c9 100644 --- a/proton-bridge/src/smtp_server.rs +++ b/proton-bridge/src/smtp_server.rs @@ -18,9 +18,17 @@ use crate::{crypto, SharedState}; pub async fn run(state: SharedState, port: u16) -> Result<(), Box> { let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await?; println!("SMTP listening on port {port}"); + run_with_listener(state, listener).await +} + +/// Accept connections on an already-bound listener. Used by the embedded bridge +/// so ports are guaranteed to be occupied before `start()` returns. +pub async fn run_with_listener( + state: SharedState, + listener: TcpListener, +) -> Result<(), Box> { loop { - let (socket, addr) = listener.accept().await?; - eprintln!("SMTP: connection from {addr}"); + let (socket, _addr) = listener.accept().await?; let state = Arc::clone(&state); tokio::spawn(async move { if let Err(e) = handle_connection(socket, state).await { @@ -54,7 +62,6 @@ async fn handle_connection( if line.is_empty() && !in_data { continue; } - eprintln!("SMTP < {line}"); // DATA body accumulation (dot-stuffing per RFC 5321 §4.5.2). if in_data { @@ -62,7 +69,6 @@ async fn handle_connection( in_data = false; let resp = handle_send(&mail_from, &rcpt_to, &data_lines, &state).await; - eprintln!("SMTP > {}", resp.trim_end()); writer.write_all(resp.as_bytes()).await?; mail_from.clear(); rcpt_to.clear(); @@ -84,7 +90,6 @@ async fn handle_connection( // Username line (base64) — we accept any username. auth_step = 2; let resp = format!("334 {}\r\n", B64.encode("Password:")); - eprintln!("SMTP > 334 (Password:)"); writer.write_all(resp.as_bytes()).await?; continue; } @@ -104,7 +109,6 @@ async fn handle_connection( } else { "535 5.7.8 Authentication credentials invalid\r\n".to_string() }; - eprintln!("SMTP > {}", resp.trim_end()); writer.write_all(resp.as_bytes()).await?; continue; } @@ -177,7 +181,6 @@ async fn handle_connection( "502 5.5.1 Command not implemented\r\n".to_string() }; - eprintln!("SMTP > {}", resp.trim_end()); writer.write_all(resp.as_bytes()).await?; } Ok(()) @@ -244,12 +247,9 @@ async fn send_to_one( .cloned() .or_else(|| st.own_public_keys.values().next().cloned()) .unwrap_or_default(); - eprintln!(" [send] own_pub_key for {canonical_email}: {} chars", opk.len()); - if opk.is_empty() { return Err("No cached sender public key — cannot encrypt draft body".to_string()); } - crypto::log_public_key_ids(&opk, "own-priv-derived"); // Find + clone the signing key for this address (first-active address key, same as opk). // PrivateKey is Clone (SignedSecretKey derives Clone), so we clone once here and @@ -266,51 +266,23 @@ async fn send_to_one( let db = match &signing_key { Some(sk) => crypto::encrypt_sign_body_armored(plaintext, &opk, sk) .map_err(|e| format!("sign+encrypt draft body: {e}"))?, - None => { - eprintln!(" [send] no signing key found, falling back to unsigned encrypt"); - crypto::encrypt_body_armored(plaintext, &opk) - .map_err(|e| format!("encrypt draft body: {e}"))? - } + None => crypto::encrypt_body_armored(plaintext, &opk) + .map_err(|e| format!("encrypt draft body: {e}"))?, }; (st.http_client.clone(), st.session.clone(), aid, db, signing_key) }; let api = ApiClient::new(&http_client, &session); - // Compare own-key IDs from two sources for diagnostics. - let server_keys = api.get_public_keys(from).await.unwrap_or_default(); - eprintln!(" [key-cmp] get_public_keys({from}) returned {} key(s)", server_keys.len()); - for (i, rk) in server_keys.iter().enumerate() { - crypto::log_public_key_ids(&rk.public_key, &format!("server-key[{i}] flags=0x{:x}", rk.flags)); - } - - // Log what key ID is actually embedded in the draft body's PKESK. - crypto::dump_message_pkesk(&draft_body); - // Fetch recipient's public keys to decide send encryption type. let recipient_keys = api.get_public_keys(to).await.unwrap_or_default(); - eprintln!( - " [send] {} recipient key(s) found for {to}", - recipient_keys.len() - ); // send_type, body, key/session-key, and whether the body is signed (Signature flag). let (send_type, body_b64, key_packet_b64, body_signed) = if let Some(rkey) = recipient_keys.iter().find(|k| k.flags & 1 != 0) { // Type 1: ProtonMail internal — encrypt to recipient's key. - eprintln!(" [send] using key flags=0x{:x}, first 30 chars of armored key: {:.30}", rkey.flags, rkey.public_key.trim()); let (pkesk, enc_body) = crypto::encrypt_body_for_send(plaintext, &rkey.public_key)?; - eprintln!( - " [send] PKESK {} bytes, first 12: {}", - pkesk.len(), - pkesk.iter().take(12).map(|b| format!("{b:02x}")).collect::() - ); - eprintln!( - " [send] SEIPD {} bytes, first 12: {}", - enc_body.len(), - enc_body.iter().take(12).map(|b| format!("{b:02x}")).collect::() - ); (1u32, B64.encode(&enc_body), B64.encode(&pkesk), false) } else { // Type 32: PGP/MIME — detached signature in a separate MIME part so the @@ -331,11 +303,9 @@ async fn send_to_one( let draft_id = api .create_draft(&address_id, subject, from, "", to, "", &draft_body) .await?; - eprintln!("SMTP draft created: {draft_id}"); api.send_draft(&draft_id, to, send_type, &body_b64, &key_packet_b64, body_signed) .await?; - eprintln!("SMTP sent to {to} (type {send_type})"); Ok(()) } diff --git a/src/config.rs b/src/config.rs index 82a4424..6ba8d6d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,26 @@ use serde::Deserialize; use std::fs; +#[derive(Debug, Deserialize, Clone, Default, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + #[default] + Imap, + Proton, +} + #[derive(Debug, Deserialize, Clone)] pub struct Config { - pub imap: ImapConfig, - pub smtp: SmtpConfig, + #[serde(default)] + pub provider: Provider, + + // Standard IMAP/SMTP providers — absent when provider = "proton" + pub imap: Option, + pub smtp: Option, + + // ProtonMail provider + pub proton: Option, + pub bridge: Option, } /// How the SMTP connection should be secured. @@ -39,10 +55,103 @@ pub struct ImapConfig { pub use_tls: bool, } +/// Mirror of `proton_bridge::config::ProtonConfig` — used for config parsing +/// without requiring the `proton` feature to be active. +#[derive(Debug, Deserialize, Clone)] +pub struct ProtonConfig { + pub username: String, + pub password: String, + pub mailbox_password: Option, + pub user_key_passphrase: Option, +} + +/// Mirror of `proton_bridge::config::BridgeConfig`. +#[derive(Debug, Deserialize, Clone)] +pub struct BridgeConfig { + pub imap_port: u16, + pub smtp_port: u16, + pub local_password: String, +} + impl Config { pub fn load() -> Result> { let content = fs::read_to_string("config.toml")?; let config: Config = toml::from_str(&content)?; Ok(config) } -} \ No newline at end of file + + /// Returns the effective IMAP config regardless of provider. + /// For `provider = "imap"` this is the `[imap]` section. + /// For `provider = "proton"` this is derived from `[bridge]`. + pub fn effective_imap(&self) -> Result { + match self.provider { + Provider::Imap => self + .imap + .clone() + .ok_or_else(|| "[imap] section missing from config.toml".to_string()), + Provider::Proton => { + let b = self + .bridge + .as_ref() + .ok_or_else(|| "[bridge] section missing from config.toml".to_string())?; + Ok(ImapConfig { + host: "127.0.0.1".into(), + port: b.imap_port, + username: "bridge".into(), + password: b.local_password.clone(), + use_tls: false, + }) + } + } + } + + /// Returns the effective SMTP config regardless of provider. + pub fn effective_smtp(&self) -> Result { + match self.provider { + Provider::Imap => self + .smtp + .clone() + .ok_or_else(|| "[smtp] section missing from config.toml".to_string()), + Provider::Proton => { + let b = self + .bridge + .as_ref() + .ok_or_else(|| "[bridge] section missing from config.toml".to_string())?; + let from = self + .proton + .as_ref() + .map(|p| p.username.clone()) + .unwrap_or_default(); + Ok(SmtpConfig { + host: "127.0.0.1".into(), + port: b.smtp_port, + username: "bridge".into(), + password: b.local_password.clone(), + tls_mode: TlsMode::None, + from, + }) + } + } + } + + /// Convert to the `proton-bridge` crate's `Config` type. + /// Only valid when `provider = "proton"`. + #[cfg(feature = "proton")] + pub fn as_bridge_config(&self) -> Result { + let p = self.proton.as_ref().ok_or("[proton] section missing")?; + let b = self.bridge.as_ref().ok_or("[bridge] section missing")?; + Ok(proton_bridge::config::Config { + proton: proton_bridge::config::ProtonConfig { + username: p.username.clone(), + password: p.password.clone(), + mailbox_password: p.mailbox_password.clone(), + user_key_passphrase: p.user_key_passphrase.clone(), + }, + bridge: proton_bridge::config::BridgeConfig { + imap_port: b.imap_port, + smtp_port: b.smtp_port, + local_password: b.local_password.clone(), + }, + }) + } +} diff --git a/src/connect.rs b/src/connect.rs index f332ad4..ee489b4 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -28,7 +28,7 @@ impl ImapSession { } pub(crate) fn connect(config: &Config) -> Result { - let imap_cfg = &config.imap; + let imap_cfg = config.imap.as_ref().expect("imap config present"); let addr = (&*imap_cfg.host, imap_cfg.port) .to_socket_addrs() diff --git a/src/lib.rs b/src/lib.rs index 97a1423..123c514 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,7 +101,7 @@ fn worker_loop( let _ = result_tx.send(WorkerResult::Searched(result)); } WorkerCmd::SendEmail { to, subject, body } => { - let smtp_cfg = config.smtp.clone(); + let smtp_cfg = config.smtp.clone().expect("smtp config present"); let tx = result_tx.clone(); thread::spawn(move || { let result = smtp::send_email(&smtp_cfg, &to, &subject, &body); diff --git a/src/main.rs b/src/main.rs index 78fd8b4..6e6bbe1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,59 @@ use std::io; +use std::process::exit; use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use tuimail::config::Config; +use tuimail::config::{Config, Provider}; use ratatui::{ backend::CrosstermBackend, Terminal, }; fn main() -> io::Result<()> { - let config = Config::load().unwrap(); + let config = Config::load().unwrap_or_else(|e| { + eprintln!("Failed to load config.toml: {e}"); + exit(1); + }); + + // ── Start bridge if needed (before entering raw mode so output is clean) ── + if config.provider == Provider::Proton { + #[cfg(feature = "proton")] + { + let bridge_cfg = config.as_bridge_config().unwrap_or_else(|e| { + eprintln!("Bridge config error: {e}"); + exit(1); + }); + eprint!("Starting ProtonMail bridge..."); + proton_bridge::start(bridge_cfg).unwrap_or_else(|e| { + eprintln!("\nBridge failed to start: {e}"); + exit(1); + }); + eprintln!(" ready."); + } + #[cfg(not(feature = "proton"))] + { + eprintln!("tuimail was not compiled with ProtonMail support."); + eprintln!("Rebuild with: cargo build --features proton"); + exit(1); + } + } + + // ── Derive effective IMAP/SMTP configs for the chosen provider ── + let imap_cfg = config.effective_imap().unwrap_or_else(|e| { + eprintln!("{e}"); + exit(1); + }); + let smtp_cfg = config.effective_smtp().unwrap_or_else(|e| { + eprintln!("{e}"); + exit(1); + }); + let runtime_config = Config { + imap: Some(imap_cfg), + smtp: Some(smtp_cfg), + ..config + }; // Restore terminal on panic let default_hook = std::panic::take_hook(); @@ -21,7 +63,7 @@ fn main() -> io::Result<()> { default_hook(info); })); - // --- Setup terminal --- + // ── Setup terminal ── let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; enable_raw_mode()?; @@ -29,12 +71,12 @@ fn main() -> io::Result<()> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let result = tuimail::main(&config, &mut terminal); + let result = tuimail::main(&runtime_config, &mut terminal); - // --- Restore terminal (always, even on error) --- + // ── Restore terminal (always, even on error) ── disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; result -} \ No newline at end of file +}