pub mod actions;
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, error, info};
use crate::console_debug;
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ActionResult;
use crate::protocol::Event;
use crate::server::Pop3Protocol;
use crate::state::app_state::AppState;
use actions::POP3_COMMAND_EVENT;
use tokio_rustls::TlsAcceptor;
pub const MAX_COMMAND_BYTES: usize = 1024;
const FIRST_COMMAND_READ_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Clone, Copy)]
struct ReadDeadlines {
first_byte: Duration,
idle: Duration,
}
impl ReadDeadlines {
fn resolve(first_byte_secs: Option<u64>, idle_secs: Option<u64>) -> Self {
Self {
first_byte: first_byte_secs
.map(Duration::from_secs)
.unwrap_or(FIRST_COMMAND_READ_TIMEOUT),
idle: idle_secs
.map(Duration::from_secs)
.unwrap_or(IDLE_BETWEEN_COMMANDS_TIMEOUT),
}
}
}
const IDLE_BETWEEN_COMMANDS_TIMEOUT: Duration = Duration::from_secs(600);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"-ERR [SYS/TEMP] too many connections\r\n";
pub const CAPA_REPLY: &[u8] =
b"+OK Capability list follows\r\nUSER\r\nTOP\r\nUIDL\r\nRESP-CODES\r\n.\r\n";
pub fn pop3_preferred_actions(command: &str) -> &'static [&'static str] {
if command == "CONNECTION_ESTABLISHED" {
return &["send_pop3_greeting"];
}
let mut words = command.split_whitespace();
let verb = words.next().unwrap_or("").to_ascii_uppercase();
let has_arg = words.next().is_some();
match (verb.as_str(), has_arg) {
("STAT", _) => &["send_pop3_stat"],
("LIST", false) => &["send_pop3_list"],
("UIDL", false) => &["send_pop3_uidl"],
("RETR", _) => &["send_pop3_retr", "send_pop3_err"],
("TOP", _) => &["send_pop3_top", "send_pop3_err"],
_ => &[],
}
}
pub fn assemble_multiline_answer(
command: &str,
result: &crate::llm::actions::executor::ExecutionResult,
) -> Option<Vec<u8>> {
if command != "RETR" && command != "TOP" {
return None;
}
let names = &result.protocol_result_actions;
if names.iter().any(|n| {
matches!(
n.as_str(),
"send_pop3_retr" | "send_pop3_top" | "send_pop3_err"
)
}) {
return None;
}
let body = names
.iter()
.zip(&result.protocol_results)
.find_map(|(name, r)| {
if name != "send_pop3_message" {
return None;
}
let text = String::from_utf8_lossy(r.get_all_output().first()?).into_owned();
(!text.starts_with("+OK") && !text.starts_with("-ERR")).then_some(text)
})?;
let body = body.replace("\r\n", "\n");
let body = body
.trim_end_matches('\n')
.strip_suffix("\n.")
.unwrap_or(body.trim_end_matches('\n'));
let framed = actions::format_pop3_multiline_body(body);
Some(format!("+OK {} octets\r\n{}.\r\n", framed.len(), framed).into_bytes())
}
pub fn pop3_command_event_data(command: &str) -> serde_json::Value {
let mut data = serde_json::json!({ "command": command });
if command == "CONNECTION_ESTABLISHED" {
data["answer_with"] = "send_pop3_greeting".into();
return data;
}
let mut words = command.split_whitespace();
let verb = words.next().unwrap_or("").to_ascii_uppercase();
let n: Option<u64> = words.next().and_then(|w| w.parse().ok());
let lines: Option<u64> = words.next().and_then(|w| w.parse().ok());
if let Some(n) = n {
data["message_number"] = n.into();
}
let answer = match (verb.as_str(), n) {
("USER", _) => "send_pop3_ok to accept the user name (the password follows), or \
send_pop3_err to refuse it"
.to_string(),
("PASS", _) => {
"send_pop3_ok to log in, or send_pop3_err to refuse the password".to_string()
}
("STAT", _) => "send_pop3_stat with message_count and total_size (octets) for the whole \
mailbox"
.to_string(),
("LIST", None) => "send_pop3_list with the id and size of every message".to_string(),
("LIST", Some(n)) => format!(
"send_pop3_ok with message \"{n} <size in octets>\" for message {n}, or \
send_pop3_err if there is no message {n}"
),
("UIDL", None) => "send_pop3_uidl with the id and unique id of every message".to_string(),
("UIDL", Some(n)) => format!(
"send_pop3_ok with message \"{n} <unique id>\" for message {n}, or send_pop3_err \
if there is no message {n}"
),
("RETR", Some(n)) => format!(
"send_pop3_retr with the whole of message {n}: its from, to, subject and body \
(NetGet writes the header lines, the octet count and the terminating '.'); \
send_pop3_err if there is no message {n}. Never a bare send_pop3_ok: the client \
waits for the message itself"
),
("TOP", Some(n)) => {
if let Some(lines) = lines {
data["lines"] = lines.into();
}
format!(
"send_pop3_top with message {n}'s headers and the first {} body lines; \
send_pop3_err if there is no message {n}",
lines.map_or_else(|| "requested".to_string(), |l| l.to_string())
)
}
("DELE", Some(n)) => format!(
"send_pop3_ok to mark message {n} deleted, or send_pop3_err if there is no message {n}"
),
("NOOP", _) | ("RSET", _) => "send_pop3_ok".to_string(),
("QUIT", _) => "send_pop3_ok, then close_connection".to_string(),
_ => return data,
};
data["answer_with"] = answer.into();
data
}
pub struct Pop3Server;
impl Pop3Server {
#[allow(clippy::too_many_arguments)]
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
tls_config: Option<Arc<rustls::ServerConfig>>,
first_byte_timeout_secs: Option<u64>,
idle_timeout_secs: Option<u64>,
) -> Result<SocketAddr> {
let deadlines = ReadDeadlines::resolve(first_byte_timeout_secs, idle_timeout_secs);
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
if tls_config.is_some() {
info!(
"POP3S server (TLS, action-based) listening on {}",
local_addr
);
} else {
info!(
"POP3 server (plain, action-based) listening on {}",
local_addr
);
}
let protocol = Arc::new(Pop3Protocol::new());
let tls_acceptor = tls_config.map(TlsAcceptor::from);
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,
"POP3",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id = crate::server::connection::ConnectionId::new(
app_state.get_next_unified_id().await,
);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
console_debug!(
status_tx,
"POP3 connection {} from {}",
connection_id,
remote_addr
);
let llm_clone = llm_client.clone();
let state_clone = app_state.clone();
let status_clone = status_tx.clone();
let protocol_clone = protocol.clone();
let tls_acceptor_clone = tls_acceptor.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
if let Some(ref acceptor) = tls_acceptor_clone {
match acceptor.accept(stream).await {
Ok(tls_stream) => {
debug!(
"TLS handshake completed for connection {}",
connection_id
);
let _ = status_clone.send(format!(
"[DEBUG] TLS handshake completed for connection {}",
connection_id
));
if let Err(e) = Pop3Session::handle_session(
tls_stream,
connection_id,
remote_addr,
local_addr_conn,
server_id,
llm_clone,
state_clone,
status_clone,
protocol_clone,
deadlines,
)
.await
{
error!(
"POP3S session error for connection {}: {}",
connection_id, e
);
}
}
Err(e) => {
error!(
"TLS handshake failed for connection {}: {}",
connection_id, e
);
}
}
} else {
if let Err(e) = Pop3Session::handle_session(
stream,
connection_id,
remote_addr,
local_addr_conn,
server_id,
llm_clone,
state_clone,
status_clone,
protocol_clone,
deadlines,
)
.await
{
error!(
"POP3 session error for connection {}: {}",
connection_id, e
);
}
}
})
.await;
}
Err(e) => {
error!("Failed to accept POP3 connection, stopping listener: {}", e);
let _ = status_tx.send(format!(
"[ERROR] POP3 listener stopped, failed to accept connection: {}",
e
));
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
}
struct Pop3Session;
impl Pop3Session {
#[allow(clippy::too_many_arguments)]
async fn handle_session<S>(
stream: S,
connection_id: crate::server::connection::ConnectionId,
remote_addr: SocketAddr,
local_addr: SocketAddr,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<Pop3Protocol>,
deadlines: ReadDeadlines,
) -> Result<()>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
let (read_half, write_half) = tokio::io::split(stream);
let reader = tokio::io::BufReader::new(read_half);
let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
let now = crate::utils::clock::Instant::now();
app_state
.add_connection_to_server(
server_id,
ConnectionState {
id: connection_id,
remote_addr,
local_addr,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
},
)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
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.clone(),
status_tx.clone(),
);
let result = Self::run_session(
reader,
&write_half,
connection_id,
server_id,
&llm_client,
&app_state,
&status_tx,
&protocol,
deadlines,
)
.await;
app_state
.remove_peer_handle(server_id, connection_id.as_u32())
.await;
app_state
.close_connection_on_server(server_id, connection_id)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
result
}
#[allow(clippy::too_many_arguments)]
async fn run_session<R, W>(
mut reader: tokio::io::BufReader<R>,
write_half: &Arc<tokio::sync::Mutex<W>>,
connection_id: crate::server::connection::ConnectionId,
server_id: crate::state::ServerId,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
protocol: &Arc<Pop3Protocol>,
deadlines: ReadDeadlines,
) -> Result<()>
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
use tokio::io::AsyncWriteExt;
let mut greeting_data = pop3_command_event_data("CONNECTION_ESTABLISHED");
greeting_data["connection_id"] = connection_id.to_string().into();
let greeting_event = Event::new(&POP3_COMMAND_EVENT, greeting_data);
match Self::process_command(
&greeting_event,
llm_client,
app_state,
status_tx,
protocol,
server_id,
connection_id,
write_half,
)
.await
{
Ok(SessionControl::Close) => return Ok(()),
Ok(SessionControl::Continue) => {}
Err(e) => {
let token = if crate::utils::WireFailure::classify(&e).is_overloaded() {
"fail_closed_llm_overloaded"
} else {
"fail_closed_llm_error"
};
error!(
"POP3 greeting on connection {} decision={}: {}",
connection_id, token, e
);
let _ = status_tx.send(format!(
"[ERROR] POP3 greeting on connection {} decision={}",
connection_id, token
));
let reply = pop3_failure_reply(&e);
let _ = status_tx.send(format!(
"[ERROR] POP3 connection {} refused: {}",
connection_id,
reply.trim_end()
));
let mut writer = write_half.lock().await;
let _ = writer.write_all(reply.as_bytes()).await;
let _ = writer.flush().await;
drop(writer);
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(reply.len() as u64),
None,
Some(1),
)
.await;
return Ok(());
}
}
let mut answered_one = false;
loop {
let read_timeout = if answered_one {
deadlines.idle
} else {
deadlines.first_byte
};
let framed = tokio::time::timeout(
read_timeout,
crate::utils::line_reader::read_bounded_line(&mut reader, MAX_COMMAND_BYTES),
)
.await;
let (read, n) = match framed {
Err(_) => {
info!(
"POP3 connection {} sent nothing for {}s; closing idle connection",
connection_id,
read_timeout.as_secs()
);
break;
}
Ok(Ok(v)) => v,
Ok(Err(e)) => {
debug!("POP3 read error on connection {}: {}", connection_id, e);
break;
}
};
answered_one = true;
let line = match read {
crate::utils::line_reader::BoundedLine::Line(line) => line,
crate::utils::line_reader::BoundedLine::Eof => {
debug!("POP3 connection {} closed by client", connection_id);
break;
}
crate::utils::line_reader::BoundedLine::TooLong => {
let reply = format!(
"-ERR [SYS/PERM] command line exceeds {MAX_COMMAND_BYTES} bytes\r\n"
);
error!(
"POP3 connection {} sent a command line over {} bytes with no newline; \
decision=fail_closed_oversized_command",
connection_id, MAX_COMMAND_BYTES
);
let _ = status_tx.send(format!(
"[ERROR] POP3 connection {} refused: {}",
connection_id,
reply.trim_end()
));
{
let mut writer = write_half.lock().await;
let _ = writer.write_all(reply.as_bytes()).await;
let _ = writer.flush().await;
}
app_state
.update_connection_stats(
server_id,
connection_id,
Some(n as u64),
Some(reply.len() as u64),
None,
Some(1),
)
.await;
break;
}
};
app_state
.update_connection_stats(
server_id,
connection_id,
Some(n as u64),
None,
Some(1),
None,
)
.await;
let command = line.trim().to_string();
if command.is_empty() {
continue;
}
let verb = command
.split_whitespace()
.next()
.unwrap_or("?")
.to_uppercase();
console_debug!(
status_tx,
"POP3 connection {} received: {}",
connection_id,
command
);
if verb == "CAPA" {
info!(
"POP3 CAPA on connection {} decision=netget_answer: answered by NetGet",
connection_id
);
let _ = status_tx.send(format!(
"[INFO] POP3 CAPA on connection {} decision=netget_answer",
connection_id
));
{
let mut writer = write_half.lock().await;
writer.write_all(CAPA_REPLY).await?;
writer.flush().await?;
}
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(CAPA_REPLY.len() as u64),
None,
Some(1),
)
.await;
continue;
}
let mut event_data = pop3_command_event_data(&command);
event_data["connection_id"] = connection_id.to_string().into();
let event = Event::new(&POP3_COMMAND_EVENT, event_data);
match Self::process_command(
&event,
llm_client,
app_state,
status_tx,
protocol,
server_id,
connection_id,
write_half,
)
.await
{
Ok(SessionControl::Continue) => {}
Ok(SessionControl::Close) => {
debug!("POP3 connection {} closed by server", connection_id);
break;
}
Err(e) => {
let token = if crate::utils::WireFailure::classify(&e).is_overloaded() {
"fail_closed_llm_overloaded"
} else {
"fail_closed_llm_error"
};
error!(
"POP3 {} on connection {} decision={}: {}",
verb, connection_id, token, e
);
let _ = status_tx.send(format!(
"[ERROR] POP3 {} on connection {} decision={}",
verb, connection_id, token
));
let reply = pop3_failure_reply(&e);
let _ = status_tx.send(format!(
"[ERROR] POP3 connection {} replying: {}",
connection_id,
reply.trim_end()
));
let mut writer = write_half.lock().await;
let _ = writer.write_all(reply.as_bytes()).await;
let _ = writer.flush().await;
drop(writer);
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(reply.len() as u64),
None,
Some(1),
)
.await;
break;
}
}
}
Ok(())
}
async fn process_command<W>(
event: &Event,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
protocol: &Arc<Pop3Protocol>,
server_id: crate::state::ServerId,
connection_id: crate::server::connection::ConnectionId,
write_half: &Arc<tokio::sync::Mutex<W>>,
) -> Result<SessionControl>
where
W: tokio::io::AsyncWrite + Unpin,
{
use tokio::io::AsyncWriteExt;
let command = event
.data
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("?")
.split_whitespace()
.next()
.unwrap_or("?")
.to_uppercase();
let llm_result = call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
event,
protocol.as_ref(),
)
.await?;
let failures = llm_result.failures.len();
let mut llm_result = llm_result;
if let Some(assembled) = assemble_multiline_answer(&command, &llm_result) {
info!(
"POP3 {} on connection {} decision=multiline_assembled: a +OK status line and \
the message as raw text, framed by NetGet",
command, connection_id
);
let _ = status_tx.send(format!(
"[INFO] POP3 {} on connection {} decision=multiline_assembled",
command, connection_id
));
llm_result.protocol_results = vec![ActionResult::Output(assembled)];
llm_result.protocol_result_actions = vec!["send_pop3_retr".to_string()];
}
let chosen = llm_result.chosen_reply(pop3_preferred_actions(
event
.data
.get("command")
.and_then(|v| v.as_str())
.unwrap_or(""),
));
let mut control = SessionControl::Continue;
let mut first_reply: Option<String> = None;
let mut asked_to_close = false;
let mut dropped_replies = 0usize;
for (index, action) in llm_result.protocol_results.into_iter().enumerate() {
match action {
ActionResult::Output(_) if Some(index) != chosen => dropped_replies += 1,
ActionResult::Output(data) => {
first_reply =
Some(String::from_utf8_lossy(&data[..data.len().min(8)]).to_string());
let mut writer = write_half.lock().await;
writer.write_all(&data).await?;
writer.flush().await?;
drop(writer);
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(data.len() as u64),
None,
Some(1),
)
.await;
console_debug!(
status_tx,
"POP3 connection {} sent {} bytes",
connection_id,
data.len()
);
}
ActionResult::CloseConnection => {
control = SessionControl::Close;
asked_to_close = true;
}
ActionResult::WaitForMore => {
}
_ => {
}
}
}
if dropped_replies > 0 {
tracing::warn!(
"POP3 {} on connection {} decision=duplicate_response_dropped: {} extra \
repl{} not sent; one command gets one reply",
command,
connection_id,
dropped_replies,
if dropped_replies == 1 { "y" } else { "ies" }
);
let _ = status_tx.send(format!(
"[WARN] POP3 {} on connection {} decision=duplicate_response_dropped ({})",
command, connection_id, dropped_replies
));
}
let decision = match first_reply.as_deref() {
Some(reply) if reply.starts_with("-ERR") => "model_reject",
Some(_) => "model_answer",
None if asked_to_close => "model_reject",
None if failures == 0 => "model_silent",
None => "fail_closed_bad_action",
};
let summary = format!(
"POP3 {} on connection {} decision={} ({} failed action(s))",
command, connection_id, decision, failures
);
match decision {
"model_silent" => {
tracing::warn!("{}", summary);
let _ = status_tx.send(format!("[WARN] {}", summary));
}
"fail_closed_bad_action" => {
error!("{}", summary);
let _ = status_tx.send(format!("[ERROR] {}", summary));
}
_ => {
info!("{}", summary);
let _ = status_tx.send(format!("[INFO] {}", summary));
}
}
Ok(control)
}
}
fn pop3_failure_reply(err: &anyhow::Error) -> String {
let failure = crate::utils::WireFailure::classify(err);
let text = failure.prefixed_text();
if failure.is_overloaded() {
format!("-ERR [SYS/TEMP] {text}\r\n")
} else {
format!("-ERR [SYS/PERM] {text}\r\n")
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SessionControl {
Continue,
Close,
}