tuimail/proton-bridge/src/imap_server.rs
Shautvast 2287b08cb5 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>
2026-02-24 22:51:30 +01:00

405 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Step 6: Local IMAP server.
///
/// Implements the nine commands skim uses (LOGIN, NOOP, SELECT, FETCH ×2,
/// SEARCH, STORE, EXPUNGE, LOGOUT) on a plain-TCP listener on localhost.
/// Each accepted connection is handled in its own tokio task.
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use crate::api::{ApiClient, LABEL_INBOX};
use crate::{crypto, SharedState};
// ── Public entry point ────────────────────────────────────────────────────────
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?;
let state = Arc::clone(&state);
tokio::spawn(async move {
if let Err(e) = handle_connection(socket, state).await {
eprintln!("IMAP connection error: {e}");
}
});
}
}
// ── Per-connection handler ────────────────────────────────────────────────────
async fn handle_connection(
socket: TcpStream,
state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let (reader, mut writer) = socket.into_split();
let mut lines = BufReader::new(reader).lines();
let mut authenticated = false;
writer
.write_all(b"* OK IMAP4rev1 ProtonBridge ready\r\n")
.await?;
while let Some(raw) = lines.next_line().await? {
let line = raw.trim_end();
if line.is_empty() {
continue;
}
// Every IMAP command is "TAG COMMAND [rest…]"
let mut parts = line.splitn(3, ' ');
let tag = parts.next().unwrap_or("*").to_string();
let cmd = parts.next().unwrap_or("").to_ascii_uppercase();
let rest = parts.next().unwrap_or("").trim().to_string();
let logout = cmd == "LOGOUT";
let response =
dispatch(&tag, &cmd, &rest, &state, &mut authenticated).await;
// for log_line in response.lines() {
// eprintln!("IMAP > {log_line}");
// }
writer.write_all(response.as_bytes()).await?;
if logout {
break;
}
}
Ok(())
}
// ── Command dispatcher ────────────────────────────────────────────────────────
async fn dispatch(
tag: &str,
cmd: &str,
rest: &str,
state: &SharedState,
authenticated: &mut bool,
) -> String {
match cmd {
"CAPABILITY" => {
format!(
"* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n\
{tag} OK CAPABILITY completed\r\n"
)
}
"LOGIN" => {
let mut p = rest.splitn(2, ' ');
let _user = unquote(p.next().unwrap_or(""));
let pass = unquote(p.next().unwrap_or(""));
let local_pw = state.lock().await.config.bridge.local_password.clone();
if pass == local_pw {
*authenticated = true;
format!("{tag} OK LOGIN completed\r\n")
} else {
format!("{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n")
}
}
"NOOP" => format!("{tag} OK NOOP completed\r\n"),
"LOGOUT" => format!("* BYE Logging out\r\n{tag} OK LOGOUT completed\r\n"),
// All remaining commands require authentication.
_ if !*authenticated => {
format!("{tag} NO Not authenticated\r\n")
}
"SELECT" => cmd_select(tag, state).await,
"EXAMINE" => cmd_select(tag, state).await, // read-only alias
"FETCH" => cmd_fetch(tag, rest, state).await,
"SEARCH" => cmd_search(tag, rest, state).await,
"STORE" => cmd_store(tag, rest, state).await,
"EXPUNGE" => cmd_expunge(tag, state).await,
_ => format!("{tag} BAD Unknown or unimplemented command\r\n"),
}
}
// ── SELECT ────────────────────────────────────────────────────────────────────
async fn cmd_select(tag: &str, state: &SharedState) -> String {
let (http_client, session) = {
let st = state.lock().await;
(st.http_client.clone(), st.session.clone())
};
let api = ApiClient::new(&http_client, &session);
let messages = match api.list_messages(LABEL_INBOX, 0, 50).await {
Ok((msgs, _)) => msgs,
Err(e) => {
eprintln!("SELECT list_messages failed: {e}");
return format!("{tag} NO SELECT failed\r\n");
}
};
let count = {
let mut st = state.lock().await;
st.store.load_all(messages);
st.store.count()
};
format!(
"* {count} EXISTS\r\n\
* 0 RECENT\r\n\
* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n\
* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited\r\n\
* OK [UIDVALIDITY 1] UIDs valid\r\n\
* OK [UIDNEXT {next}] Predicted next UID\r\n\
{tag} OK [READ-WRITE] SELECT completed\r\n",
next = count + 1,
)
}
// ── FETCH ─────────────────────────────────────────────────────────────────────
async fn cmd_fetch(tag: &str, rest: &str, state: &SharedState) -> String {
// rest = "seq_set ITEMS" or "seq_set (ITEMS)"
let mut parts = rest.splitn(2, ' ');
let seq_set = parts.next().unwrap_or("").to_string();
let items = parts
.next()
.unwrap_or("")
.trim()
.trim_matches(|c| c == '(' || c == ')')
.to_ascii_uppercase();
if items.contains("HEADER.FIELDS") {
fetch_headers(tag, &seq_set, state).await
} else {
fetch_body(tag, &seq_set, state).await
}
}
async fn fetch_headers(tag: &str, seq_set: &str, state: &SharedState) -> String {
// Collect all metadata under a single short lock — no I/O while locked.
let rows: Vec<(u32, String, String, String, i64)> = {
let st = state.lock().await;
let count = st.store.count();
parse_seq_set(seq_set, count)
.into_iter()
.filter_map(|seq| {
st.store.get_meta(seq).map(|m| {
(
seq,
m.subject.clone(),
m.sender.name.clone(),
m.sender.address.clone(),
m.time,
)
})
})
.collect()
};
let mut out = Vec::new();
for (seq, subject, name, addr, time) in &rows {
let block = format!(
"Subject: {subject}\r\nFrom: {name} <{addr}>\r\nDate: {date}\r\n\r\n",
date = rfc2822(*time),
);
let n = block.as_bytes().len();
out.extend_from_slice(
format!(
"* {seq} FETCH (BODY[HEADER.FIELDS (SUBJECT FROM DATE)] {{{n}}}\r\n"
)
.as_bytes(),
);
out.extend_from_slice(block.as_bytes());
out.extend_from_slice(b")\r\n");
}
out.extend_from_slice(format!("{tag} OK FETCH completed\r\n").as_bytes());
String::from_utf8_lossy(&out).into_owned()
}
async fn fetch_body(tag: &str, seq_set: &str, state: &SharedState) -> String {
let count = state.lock().await.store.count();
let seqs = parse_seq_set(seq_set, count);
let seq = match seqs.first() {
Some(&s) => s,
None => return format!("{tag} NO invalid sequence\r\n"),
};
// Grab proton_id + connection details under a brief lock.
let (proton_id, meta_snapshot, http_client, session) = {
let st = state.lock().await;
let m = match st.store.get_meta(seq) {
Some(m) => (
m.subject.clone(),
m.sender.name.clone(),
m.sender.address.clone(),
m.time,
),
None => return format!("{tag} NO no such message\r\n"),
};
let id = st.store.proton_id(seq).unwrap_or("").to_string();
(id, m, st.http_client.clone(), st.session.clone())
};
// API call — lock is released.
let api = ApiClient::new(&http_client, &session);
let full_msg = match api.get_message(&proton_id).await {
Ok(m) => m,
Err(e) => {
eprintln!("FETCH get_message failed: {e}");
return format!("{tag} NO fetch failed\r\n");
}
};
// Decrypt — re-acquire lock only for the synchronous crypto step.
let plaintext = {
let st = state.lock().await;
let keys: Vec<&crypto::PrivateKey> = st.key_pool.iter().collect();
match crypto::decrypt_body(&full_msg.body, &keys) {
Ok(p) => p,
Err(e) => {
eprintln!("FETCH decrypt_body failed: {e}");
return format!("{tag} NO decrypt failed\r\n");
}
}
};
let (subject, sender_name, sender_addr, time) = meta_snapshot;
let message = format!(
"From: {sender_name} <{sender_addr}>\r\n\
Subject: {subject}\r\n\
Date: {date}\r\n\
Content-Type: {mime}; charset=utf-8\r\n\
MIME-Version: 1.0\r\n\
\r\n\
{plaintext}",
date = rfc2822(time),
mime = full_msg.mime_type,
);
let n = message.as_bytes().len();
let mut out = Vec::new();
out.extend_from_slice(
format!("* {seq} FETCH (BODY[] {{{n}}}\r\n").as_bytes(),
);
out.extend_from_slice(message.as_bytes());
out.extend_from_slice(b")\r\n");
out.extend_from_slice(format!("{tag} OK FETCH completed\r\n").as_bytes());
String::from_utf8_lossy(&out).into_owned()
}
// ── SEARCH ────────────────────────────────────────────────────────────────────
async fn cmd_search(tag: &str, criteria: &str, state: &SharedState) -> String {
// skim sends: OR SUBJECT "query" FROM "query"
let query = extract_quoted(criteria).unwrap_or("");
let seqs = state
.lock()
.await
.store
.search_subject_or_from(query);
let nums: Vec<String> = seqs.iter().map(|n| n.to_string()).collect();
format!(
"* SEARCH {}\r\n{tag} OK SEARCH completed\r\n",
nums.join(" ")
)
}
// ── STORE ─────────────────────────────────────────────────────────────────────
async fn cmd_store(tag: &str, rest: &str, state: &SharedState) -> String {
// rest = "seq_set +FLAGS (\Deleted)" (or similar)
let mut parts = rest.splitn(2, ' ');
let seq_set = parts.next().unwrap_or("").to_string();
let flags = parts.next().unwrap_or("").to_ascii_uppercase();
if !flags.contains("DELETED") {
return format!("{tag} OK STORE completed\r\n");
}
let mut st = state.lock().await;
let count = st.store.count();
let seqs = parse_seq_set(&seq_set, count);
let mut out = String::new();
for seq in seqs {
st.store.mark_deleted(seq);
out.push_str(&format!("* {seq} FETCH (FLAGS (\\Deleted))\r\n"));
}
out.push_str(&format!("{tag} OK STORE completed\r\n"));
out
}
// ── EXPUNGE ───────────────────────────────────────────────────────────────────
async fn cmd_expunge(tag: &str, state: &SharedState) -> String {
let (proton_ids, removed_seqs, http_client, session) = {
let mut st = state.lock().await;
let (ids, seqs) = st.store.expunge();
(ids, seqs, st.http_client.clone(), st.session.clone())
};
if !proton_ids.is_empty() {
let api = ApiClient::new(&http_client, &session);
if let Err(e) = api.delete_messages(&proton_ids).await {
eprintln!("EXPUNGE delete_messages failed: {e}");
}
}
let mut out = String::new();
for seq in &removed_seqs {
out.push_str(&format!("* {seq} EXPUNGE\r\n"));
}
out.push_str(&format!("{tag} OK EXPUNGE completed\r\n"));
out
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn unquote(s: &str) -> &str {
s.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(s)
}
/// Extract the first double-quoted string from `s`.
fn extract_quoted(s: &str) -> Option<&str> {
let start = s.find('"')? + 1;
let end = s[start..].find('"')? + start;
Some(&s[start..end])
}
/// Parse an IMAP sequence set (e.g. "1:50", "3,5,7", "42", "*") into a Vec of
/// seq numbers, clamped to [1, max].
fn parse_seq_set(set: &str, max: u32) -> Vec<u32> {
if max == 0 {
return vec![];
}
let mut result = Vec::new();
for part in set.split(',') {
let part = part.trim();
if let Some((a, b)) = part.split_once(':') {
let start: u32 = if a == "*" { max } else { a.parse().unwrap_or(1) };
let end: u32 = if b == "*" { max } else { b.parse().unwrap_or(max) };
let (lo, hi) = (start.min(end), start.max(end));
for seq in lo..=hi.min(max) {
result.push(seq);
}
} else if part == "*" {
result.push(max);
} else {
let seq: u32 = part.parse().unwrap_or(0);
if seq >= 1 && seq <= max {
result.push(seq);
}
}
}
result
}
/// Format a Unix timestamp as an RFC 2822 date string.
fn rfc2822(unix: i64) -> String {
use chrono::{DateTime, Utc};
DateTime::<Utc>::from_timestamp(unix, 0)
.unwrap_or_default()
.format("%a, %d %b %Y %H:%M:%S +0000")
.to_string()
}