tuimail/proton-bridge/src/main.rs
Shautvast fa1d1a6cb7 Add IMAP server (step 6) — all 9 commands implemented
Plain-TCP listener on 0.0.0.0 (handles both localhost and 127.0.0.1).
LOGIN, NOOP, SELECT (reloads inbox), FETCH header+body, SEARCH, STORE,
EXPUNGE (deletes on ProtonMail), LOGOUT.

FETCH body decrypts messages on demand: brief lock for ID lookup, API call
without lock, brief lock again for crypto. RFC 3501 literal format with
exact byte counts for imap-crate compatibility.

Also: update store.expunge() to return (ids, seqs) in descending order for
correct IMAP EXPUNGE response ordering; add chrono for RFC 2822 dates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 19:13:20 +01:00

221 lines
7 KiB
Rust

mod api;
mod auth;
mod config;
mod crypto;
mod imap_server;
mod srp;
mod store;
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,
}
pub type SharedState = Arc<Mutex<BridgeState>>;
// ── Entry point ───────────────────────────────────────────────────────────────
#[tokio::main]
async 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 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.
let key_pool = 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());
// 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(),
}));
// 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}");
}
});
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");
println!("Shutting down.");
}
// ── Key pool setup ────────────────────────────────────────────────────────────
async fn unlock_key_pool(
api: &ApiClient<'_>,
config: &config::Config,
) -> Result<Vec<crypto::PrivateKey>, 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.
let mut key_pool: Vec<crypto::PrivateKey> = Vec::new();
for addr in &addresses {
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",
&ak.id[..8.min(ak.id.len())],
addr.email
);
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);
Ok(key_pool)
}