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 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-24 22:51:30 +01:00
parent 8826f3bc95
commit 2287b08cb5
15 changed files with 548 additions and 362 deletions

2
.gitignore vendored
View file

@ -1,6 +1,6 @@
/target
docker-data/
config.toml
config.toml.old
.idea/
bridge.toml
session.json

1
Cargo.lock generated
View file

@ -4212,6 +4212,7 @@ dependencies = [
"lettre",
"mailparse",
"native-tls",
"proton-bridge",
"quoted_printable",
"ratatui",
"regex",

View file

@ -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"

View file

@ -38,14 +38,50 @@ from = "Your Name <you@example.com>"
|----------|-----------|-----------|---------|-----------|-----------|----------|
| 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

View file

@ -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 <test@example.com>"
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 <you@example.com>"
# ── 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

View file

@ -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<KeySaltsPayload> =
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))

View file

@ -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<Self, Box<dyn std::error::Error>> {
let content = fs::read_to_string("bridge.toml")?;
Ok(toml::from_str(&content)?)
}
}

View file

@ -16,9 +16,17 @@ use crate::{crypto, SharedState};
pub async fn run(state: SharedState, port: u16) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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;

262
proton-bridge/src/lib.rs Normal file
View file

@ -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<crypto::PrivateKey>,
pub http_client: reqwest::Client,
pub session: auth::Session,
pub config: config::Config,
pub sender_addresses: Vec<(String, String)>,
pub own_public_keys: HashMap<String, String>,
pub sender_key_indices: HashMap<String, usize>,
}
pub type SharedState = Arc<Mutex<BridgeState>>;
// ── 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::<Result<(), String>>();
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<crypto::PrivateKey>,
Vec<(String, String)>,
HashMap<String, String>,
HashMap<String, usize>,
),
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<crypto::PrivateKey> = 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<crypto::PrivateKey> = Vec::new();
let mut own_public_keys: HashMap<String, String> = HashMap::new();
let mut sender_key_indices: HashMap<String, usize> = 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))
}

View file

@ -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<crypto::PrivateKey>,
/// 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<String, String>,
/// 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<String, usize>,
}
pub type SharedState = Arc<Mutex<BridgeState>>;
// ── 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<crypto::PrivateKey>, Vec<(String, String)>, HashMap<String, String>, HashMap<String, usize>), 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<crypto::PrivateKey> = 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<crypto::PrivateKey> = Vec::new();
let mut own_public_keys: HashMap<String, String> = HashMap::new();
let mut sender_key_indices: HashMap<String, usize> = 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))
}

View file

@ -18,9 +18,17 @@ use crate::{crypto, SharedState};
pub async fn run(state: SharedState, port: u16) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<String>()
);
eprintln!(
" [send] SEIPD {} bytes, first 12: {}",
enc_body.len(),
enc_body.iter().take(12).map(|b| format!("{b:02x}")).collect::<String>()
);
(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(())
}

View file

@ -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<ImapConfig>,
pub smtp: Option<SmtpConfig>,
// ProtonMail provider
pub proton: Option<ProtonConfig>,
pub bridge: Option<BridgeConfig>,
}
/// 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<String>,
pub user_key_passphrase: Option<String>,
}
/// 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<Self, Box<dyn std::error::Error>> {
let content = fs::read_to_string("config.toml")?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
}
/// 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<ImapConfig, String> {
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<SmtpConfig, String> {
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<proton_bridge::config::Config, String> {
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(),
},
})
}
}

View file

@ -28,7 +28,7 @@ impl ImapSession {
}
pub(crate) fn connect(config: &Config) -> Result<ImapSession, String> {
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()

View file

@ -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);

View file

@ -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
}
}