pub mod actions;
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
#[cfg(not(target_arch = "wasm32"))]
use crate::llm::action_helper::call_llm;
#[cfg(not(target_arch = "wasm32"))]
use crate::llm::ollama_client::OllamaClient;
#[cfg(not(target_arch = "wasm32"))]
use crate::llm::ActionResult;
#[cfg(not(target_arch = "wasm32"))]
use crate::logging::emit::Log;
#[cfg(not(target_arch = "wasm32"))]
use crate::protocol::Event;
#[cfg(not(target_arch = "wasm32"))]
use crate::server::connection::ConnectionId;
#[cfg(not(target_arch = "wasm32"))]
use crate::server::ImapProtocol;
#[cfg(not(target_arch = "wasm32"))]
use crate::state::app_state::AppState;
#[cfg(not(target_arch = "wasm32"))]
use crate::state::server::{
ConnectionStatus, ImapSessionState, ProtocolConnectionInfo, ProtocolState, ServerId,
};
#[cfg(not(target_arch = "wasm32"))]
use actions::{IMAP_AUTH_EVENT, IMAP_CONNECTION_EVENT};
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
#[cfg(not(target_arch = "wasm32"))]
pub const MAX_COMMAND_BYTES: usize = 8192;
#[cfg(not(target_arch = "wasm32"))]
const FIRST_COMMAND_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[cfg(not(target_arch = "wasm32"))]
const IDLE_BETWEEN_COMMANDS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2100);
#[cfg(not(target_arch = "wasm32"))]
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
#[cfg(not(target_arch = "wasm32"))]
const CONNECTION_CAP_REFUSAL: &[u8] = b"* BYE [UNAVAILABLE] too many connections\r\n";
pub struct ImapServer;
#[cfg(not(target_arch = "wasm32"))]
impl ImapServer {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: ServerId,
) -> Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
info!("IMAP server (action-based) listening on {}", local_addr);
Log::new(Some(&status_tx)).info(format!("IMAP server listening on {}", local_addr));
let protocol = Arc::new(ImapProtocol::new());
let task_registrar = app_state.clone();
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"IMAP",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
debug!("IMAP connection {} from {}", connection_id, remote_addr);
let _ = status_tx.send(format!(
"โ IMAP connection {} from {}",
connection_id, remote_addr
));
let local_addr = stream.local_addr().unwrap_or(listen_addr);
let (read_half, write_half) = tokio::io::split(stream);
let write_half_arc = Arc::new(tokio::sync::Mutex::new(write_half));
app_state
.add_connection_to_server(
server_id,
crate::state::ConnectionState {
id: connection_id,
remote_addr,
local_addr,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: crate::utils::clock::Instant::now(),
status: ConnectionStatus::Active,
status_changed_at: crate::utils::clock::Instant::now(),
protocol_info: ProtocolConnectionInfo::empty(),
},
)
.await;
let peer_rx = crate::server::peer_support::register_peer_channel(
&app_state,
server_id,
connection_id.as_u32(),
)
.await;
crate::server::peer_support::spawn_peer_command_task(
peer_rx,
protocol.clone(),
app_state.clone(),
server_id,
connection_id.as_u32(),
write_half_arc.clone(),
status_tx.clone(),
);
let _ = status_tx.send("__UPDATE_UI__".to_string());
let llm_clone = llm_client.clone();
let state_clone = app_state.clone();
let status_clone = status_tx.clone();
let protocol_clone = protocol.clone();
let write_half_for_session = write_half_arc.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
let mut session = ImapSession {
reader: BufReader::new(read_half),
writer: write_half_for_session,
connection_id,
server_id,
remote_addr,
llm_client: llm_clone,
app_state: state_clone.clone(),
status_tx: status_clone.clone(),
protocol: protocol_clone,
capabilities: DEFAULT_CAPABILITIES.to_string(),
};
if let Err(e) = session.handle().await {
error!("IMAP session error for {}: {}", connection_id, e);
Log::new(Some(&status_clone)).error(format!(
"IMAP session {} error: {}",
connection_id, e
));
}
state_clone
.remove_peer_handle(server_id, connection_id.as_u32())
.await;
state_clone
.update_connection_status(
server_id,
connection_id,
ConnectionStatus::Closed,
)
.await;
info!("IMAP connection {} closed", connection_id);
let _ = status_clone
.send(format!("โ IMAP connection {} closed", connection_id));
})
.await;
}
Err(e) => {
Log::new(Some(&status_tx))
.error(format!("Failed to accept IMAP connection: {}", e));
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
}
pub const DEFAULT_CAPABILITIES: &str = "IMAP4rev1";
pub fn greeting_capabilities(greeting: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(greeting);
let start = text.find("[CAPABILITY ")? + "[CAPABILITY ".len();
let end = start + text[start..].find(']')?;
Some(with_imap4rev1(&text[start..end]))
}
pub fn with_imap4rev1(list: &str) -> String {
let mut caps = vec![DEFAULT_CAPABILITIES.to_string()];
caps.extend(
list.split_whitespace()
.filter(|c| !c.eq_ignore_ascii_case(DEFAULT_CAPABILITIES))
.map(str::to_string),
);
caps.join(" ")
}
pub fn imap_answer_with(tag: &str, command: &str, args: &str) -> Option<String> {
let mailbox = args
.split_whitespace()
.next()
.unwrap_or("")
.trim_matches('"');
Some(match command.to_ascii_uppercase().as_str() {
"LOGIN" => format!(
"send_imap_response with tag {tag} and status OK to accept the login, or status NO \
to refuse it"
),
"SELECT" | "EXAMINE" => format!(
"send_imap_select with exists = the number of messages in {mailbox} (and recent, \
unseen, uidnext when you know them); NetGet adds the tagged OK. If there is no \
mailbox {mailbox}, send_imap_response with tag {tag} and status NO"
),
"LIST" | "LSUB" => "send_imap_list with one entry for every mailbox your instruction \
names, and only those (its name and the delimiter \"/\"); NetGet \
adds the tagged OK"
.to_string(),
"STATUS" => format!(
"send_imap_status for mailbox {mailbox} with the items the client asked for; \
NetGet adds the tagged OK"
),
"FETCH" => {
"send_imap_fetch, one per message asked for; NetGet adds the tagged OK".to_string()
}
"SEARCH" => "send_imap_search with the matching message numbers ([] for none); \
NetGet adds the tagged OK"
.to_string(),
"NOOP" | "CHECK" | "CLOSE" | "EXPUNGE" | "SUBSCRIBE" | "UNSUBSCRIBE" => {
format!("send_imap_response with tag {tag} and status OK")
}
"LOGOUT" => format!(
"send_imap_untagged with response_type BYE, then send_imap_response with tag {tag} \
and status OK"
),
_ => return None,
})
}
#[cfg(not(target_arch = "wasm32"))]
struct ImapSession<R, W> {
reader: BufReader<R>,
writer: Arc<tokio::sync::Mutex<W>>,
connection_id: ConnectionId,
server_id: ServerId,
#[allow(dead_code)]
remote_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<ImapProtocol>,
capabilities: String,
}
#[cfg(not(target_arch = "wasm32"))]
impl<R: tokio::io::AsyncRead + Unpin, W: tokio::io::AsyncWrite + Unpin> ImapSession<R, W> {
async fn handle(&mut self) -> Result<()> {
if let Err(e) = self.send_greeting().await {
let (code, detail) = imap_failure_code(&e);
error!(
"IMAP greeting handler failed on connection {}: {}",
self.connection_id, e
);
Log::new(Some(&self.status_tx)).error(format!(
"IMAP connection {} refused with BYE [{}]: {}",
self.connection_id, code, e
));
let bye = format!("* BYE [{}] {}\r\n", code, detail);
let _ = self.send_response(bye.as_bytes()).await;
return Ok(());
}
let mut answered_one = false;
loop {
let read_deadline = if answered_one {
IDLE_BETWEEN_COMMANDS_TIMEOUT
} else {
FIRST_COMMAND_READ_TIMEOUT
};
let read_result = match tokio::time::timeout(
read_deadline,
crate::utils::line_reader::read_bounded_line(&mut self.reader, MAX_COMMAND_BYTES),
)
.await
{
Ok(v) => v,
Err(_) => {
info!(
"IMAP connection {} sent nothing for {}s; closing \
decision=fail_closed_idle_timeout",
self.connection_id,
read_deadline.as_secs()
);
let _ = self
.send_response(b"* BYE [UNAVAILABLE] idle timeout\r\n")
.await;
break;
}
};
let (read, n) = match read_result {
Ok(v) => v,
Err(e) => {
debug!("IMAP read error on {}: {}", self.connection_id, e);
break;
}
};
let line = match read {
crate::utils::line_reader::BoundedLine::Line(line) => line,
crate::utils::line_reader::BoundedLine::Eof => {
debug!("IMAP client {} disconnected", self.connection_id);
break;
}
crate::utils::line_reader::BoundedLine::TooLong => {
error!(
"IMAP connection {} sent a command line over {} bytes with no newline; \
decision=fail_closed_oversized_command",
self.connection_id, MAX_COMMAND_BYTES
);
Log::new(Some(&self.status_tx)).error(format!(
"IMAP connection {} refused: command line exceeds {} bytes",
self.connection_id, MAX_COMMAND_BYTES
));
let bye = format!(
"* BYE [UNAVAILABLE] command line exceeds {MAX_COMMAND_BYTES} bytes\r\n"
);
let _ = self.send_response(bye.as_bytes()).await;
break;
}
};
answered_one = true;
{
trace!(
"IMAP received {} bytes from {}: {}",
n,
self.connection_id,
line.trim()
);
Log::new(Some(&self.status_tx)).trace(format!("IMAP command: {}", line.trim()));
self.app_state
.update_connection_stats(
self.server_id,
self.connection_id,
Some(n as u64),
None,
Some(1),
None,
)
.await;
if let Err(e) = self.handle_command(&line).await {
let (code, detail) = imap_failure_code(&e);
error!(
"Error handling IMAP command on connection {}: {}",
self.connection_id, e
);
Log::new(Some(&self.status_tx)).error(format!(
"IMAP connection {} refusing command with NO [{}]: {}",
self.connection_id, code, e
));
let (tag, _, _) = parse_imap_command(&line);
let error_response = format!("{} NO [{}] {}\r\n", tag, code, detail);
let _ = self.send_response(error_response.as_bytes()).await;
}
if let Some((session_state, _, _)) = self
.app_state
.get_imap_connection_state(self.server_id, self.connection_id)
.await
{
if session_state == ImapSessionState::Logout {
break;
}
}
}
}
Ok(())
}
async fn send_greeting(&mut self) -> Result<()> {
let event = Event::new(&IMAP_CONNECTION_EVENT, json!({}));
let result = call_llm(
&self.llm_client,
&self.app_state,
self.server_id,
Some(self.connection_id),
&event,
self.protocol.as_ref(),
)
.await?;
let mut wrote_greeting = false;
let mut dropped_replies = 0usize;
for action_result in result.protocol_results {
match action_result {
ActionResult::Output(_) if wrote_greeting => dropped_replies += 1,
ActionResult::Output(data) => {
wrote_greeting = true;
if let Some(capabilities) = greeting_capabilities(&data) {
self.capabilities = capabilities;
}
self.send_response(&data).await?;
}
ActionResult::CloseConnection => {
}
_ => {}
}
}
self.log_dropped_replies("greeting", dropped_replies);
Ok(())
}
fn log_dropped_replies(&self, what: &str, dropped: usize) {
if dropped > 0 {
Log::new(Some(&self.status_tx)).warn(format!(
"IMAP {} on connection {} decision=duplicate_response_dropped: {} extra \
repl{} not sent; the command was already complete",
what,
self.connection_id,
dropped,
if dropped == 1 { "y" } else { "ies" }
));
}
}
fn own_tag(&self, data: &[u8], tag: &str, command: &str) -> Vec<u8> {
match retag_completion(data, tag) {
Some(fixed) => {
Log::new(Some(&self.status_tx)).warn(format!(
"IMAP {} {} on connection {} decision=completion_retagged: the model's \
completion {:?} carried another tag",
tag,
command.to_uppercase(),
self.connection_id,
String::from_utf8_lossy(data).trim_end()
));
fixed
}
None => data.to_vec(),
}
}
async fn handle_command(&mut self, line: &str) -> Result<()> {
let (tag, command, args) = parse_imap_command(line);
debug!(
"IMAP command from {}: tag={}, command={}, args={}",
self.connection_id, tag, command, args
);
let (session_state, authenticated_user, selected_mailbox) = self
.app_state
.get_imap_connection_state(self.server_id, self.connection_id)
.await
.unwrap_or((ImapSessionState::NotAuthenticated, None, None));
if command.to_uppercase() == "LOGIN" {
return self.handle_login(&tag, &args).await;
}
if command.eq_ignore_ascii_case("CAPABILITY") && args.trim().is_empty() {
Log::new(Some(&self.status_tx)).info(format!(
"IMAP {} CAPABILITY on connection {} decision=netget_answer: {}",
tag, self.connection_id, self.capabilities
));
let reply = format!(
"* CAPABILITY {}\r\n{} OK CAPABILITY completed\r\n",
self.capabilities, tag
);
return self.send_response(reply.as_bytes()).await;
}
let mut event_data = json!({
"tag": tag,
"command": command,
"args": args,
"session_state": format!("{:?}", session_state),
"authenticated_user": authenticated_user,
"selected_mailbox": selected_mailbox,
});
if let Some(answer_with) = imap_answer_with(&tag, &command, &args) {
event_data["answer_with"] = answer_with.into();
}
let event = Event::new(actions::command_event(&command), event_data);
let result = call_llm(
&self.llm_client,
&self.app_state,
self.server_id,
Some(self.connection_id),
&event,
self.protocol.as_ref(),
)
.await?;
let mut sent_any_output = false;
let mut deferred = false;
let mut tagged_completion_ok = false;
let mut dropped_replies = 0usize;
let mut untagged: Vec<Vec<u8>> = Vec::new();
let mut completion: Option<Vec<u8>> = None;
for action_result in result.protocol_results {
match action_result {
ActionResult::Output(data) => {
let data = self.own_tag(&data, &tag, &command);
let text = String::from_utf8_lossy(&data);
let is_tagged = text.lines().any(|line| {
line.trim_start()
.to_uppercase()
.starts_with(&format!("{} ", tag.to_uppercase()))
});
if !is_tagged {
if untagged.contains(&data) {
dropped_replies += 1;
} else {
untagged.push(data);
}
} else if completion.is_none() {
tagged_completion_ok = tagged_ok_for(&text, &tag);
completion = Some(data);
} else {
dropped_replies += 1;
}
}
ActionResult::CloseConnection => {
deferred = true;
self.app_state
.update_imap_session_state(
self.server_id,
self.connection_id,
ImapSessionState::Logout,
)
.await;
}
ActionResult::WaitForMore => {
deferred = true;
self.app_state
.update_imap_protocol_state(
self.server_id,
self.connection_id,
ProtocolState::Accumulating,
)
.await;
}
_ => {}
}
}
for data in untagged.iter().chain(completion.iter()) {
sent_any_output = true;
self.send_response(data).await?;
}
let sent_tagged_completion = completion.is_some();
self.log_dropped_replies(&command.to_uppercase(), dropped_replies);
if !sent_tagged_completion && !deferred {
let completion = if sent_any_output {
format!("{} OK {} completed\r\n", tag, command.to_uppercase())
} else {
warn!(
"IMAP command {} on connection {} produced no response; answering NO",
command, self.connection_id
);
format!(
"{} NO netget: no response was produced for {}\r\n",
tag,
command.to_uppercase()
)
};
debug!(
"IMAP appending missing tagged completion for {}: {}",
command,
completion.trim_end()
);
self.send_response(completion.as_bytes()).await?;
}
let command_ok = if deferred {
false
} else if sent_tagged_completion {
tagged_completion_ok
} else {
sent_any_output
};
self.update_session_state(&command, &args, command_ok)
.await?;
Ok(())
}
async fn handle_login(&mut self, tag: &str, args: &str) -> Result<()> {
let parts: Vec<&str> = args.split_whitespace().collect();
if parts.len() < 2 {
let response = format!("{} BAD LOGIN requires username and password\r\n", tag);
return self.send_response(response.as_bytes()).await;
}
let username = parts[0].trim_matches('"');
let password = parts[1].trim_matches('"');
debug!("IMAP LOGIN attempt: username={}", username);
let event = Event::new(
&IMAP_AUTH_EVENT,
json!({
"tag": tag,
"username": username,
"password": password,
"answer_with": imap_answer_with(tag, "LOGIN", args),
}),
);
let result = call_llm(
&self.llm_client,
&self.app_state,
self.server_id,
Some(self.connection_id),
&event,
self.protocol.as_ref(),
)
.await?;
let mut auth_success = false;
let mut completed = false;
let mut dropped_replies = 0usize;
for action_result in &result.protocol_results {
if let ActionResult::Output(data) = action_result {
if completed {
dropped_replies += 1;
continue;
}
let data = self.own_tag(data, tag, "LOGIN");
let response = String::from_utf8_lossy(&data);
if tagged_ok_for(&response, tag) {
auth_success = true;
}
completed = response
.lines()
.any(|line| line.split_whitespace().next() == Some(tag));
self.send_response(&data).await?;
} else if let ActionResult::CloseConnection = action_result {
self.app_state
.update_imap_session_state(
self.server_id,
self.connection_id,
ImapSessionState::Logout,
)
.await;
}
}
self.log_dropped_replies("LOGIN", dropped_replies);
if auth_success {
self.app_state
.update_imap_connection_state(
self.server_id,
self.connection_id,
Some(ImapSessionState::Authenticated),
Some(Some(username.to_string())),
None,
None,
)
.await;
debug!("IMAP user {} authenticated", username);
}
Ok(())
}
async fn update_session_state(
&mut self,
command: &str,
args: &str,
command_ok: bool,
) -> Result<()> {
let cmd_upper = command.to_uppercase();
if !command_ok && cmd_upper != "LOGOUT" {
debug!(
"IMAP {} on connection {} did not succeed (decision=no_state_change); \
session state unchanged",
cmd_upper, self.connection_id
);
return Ok(());
}
match cmd_upper.as_str() {
"SELECT" => {
let mailbox = args.split_whitespace().next().unwrap_or("INBOX");
self.app_state
.update_imap_connection_state(
self.server_id,
self.connection_id,
Some(ImapSessionState::Selected),
None,
Some(Some(mailbox.trim_matches('"').to_string())),
Some(false),
)
.await;
debug!("IMAP mailbox selected: {}", mailbox);
}
"EXAMINE" => {
let mailbox = args.split_whitespace().next().unwrap_or("INBOX");
self.app_state
.update_imap_connection_state(
self.server_id,
self.connection_id,
Some(ImapSessionState::Selected),
None,
Some(Some(mailbox.trim_matches('"').to_string())),
Some(true),
)
.await;
debug!("IMAP mailbox examined (read-only): {}", mailbox);
}
"CLOSE" => {
self.app_state
.update_imap_connection_state(
self.server_id,
self.connection_id,
Some(ImapSessionState::Authenticated),
None,
Some(None),
Some(false),
)
.await;
debug!("IMAP mailbox closed");
}
"LOGOUT" => {
self.app_state
.update_imap_session_state(
self.server_id,
self.connection_id,
ImapSessionState::Logout,
)
.await;
debug!("IMAP session logout");
}
_ => {}
}
Ok(())
}
async fn send_response(&mut self, data: &[u8]) -> Result<()> {
let mut writer = self.writer.lock().await;
writer.write_all(data).await?;
writer.flush().await?;
drop(writer);
self.app_state
.update_connection_stats(
self.server_id,
self.connection_id,
None,
Some(data.len() as u64),
None,
Some(1),
)
.await;
trace!("IMAP sent {} bytes to {}", data.len(), self.connection_id);
Log::new(Some(&self.status_tx)).trace(format!("IMAP sent {} bytes", data.len()));
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
fn imap_failure_code(err: &anyhow::Error) -> (&'static str, String) {
let failure = crate::utils::WireFailure::classify(err);
let code = if failure.is_overloaded() {
"UNAVAILABLE"
} else {
"SERVERBUG"
};
(code, failure.prefixed_text().to_string())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn retag_completion(data: &[u8], tag: &str) -> Option<Vec<u8>> {
let text = std::str::from_utf8(data).ok()?;
let line = text
.strip_suffix("\r\n")
.or_else(|| text.strip_suffix('\n'))
.unwrap_or(text);
if line.contains(['\r', '\n']) {
return None;
}
let mut fields = line.splitn(3, ' ');
let first = fields.next()?;
let status = fields.next()?;
if first.is_empty() || first == "*" || first == "+" || first == tag {
return None;
}
if !["OK", "NO", "BAD"]
.iter()
.any(|s| s.eq_ignore_ascii_case(status))
{
return None;
}
Some(
match fields.next() {
Some(rest) => format!("{tag} {status} {rest}\r\n"),
None => format!("{tag} {status}\r\n"),
}
.into_bytes(),
)
}
fn tagged_ok_for(payload: &str, tag: &str) -> bool {
payload.lines().any(|line| {
let mut fields = line.trim().split_whitespace();
fields.next() == Some(tag) && fields.next().is_some_and(|status| status == "OK")
})
}
#[cfg(not(target_arch = "wasm32"))]
fn parse_imap_command(line: &str) -> (String, String, String) {
let trimmed = line.trim();
let parts: Vec<&str> = trimmed.splitn(3, ' ').collect();
match parts.len() {
0 => ("*".to_string(), "".to_string(), "".to_string()),
1 => (parts[0].to_string(), "".to_string(), "".to_string()),
2 => (parts[0].to_string(), parts[1].to_string(), "".to_string()),
_ => (
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
),
}
}