netget/server/pop3/mod.rs
1//! POP3 server implementation
2pub mod actions;
3
4use anyhow::Result;
5use std::net::SocketAddr;
6use std::sync::Arc;
7use std::time::Duration;
8use tokio::sync::mpsc;
9use tracing::{debug, error, info};
10
11use crate::console_debug;
12use crate::llm::action_helper::call_llm;
13use crate::llm::ollama_client::OllamaClient;
14use crate::llm::ActionResult;
15use crate::protocol::Event;
16use crate::server::Pop3Protocol;
17use crate::state::app_state::AppState;
18use actions::POP3_COMMAND_EVENT;
19use tokio_rustls::TlsAcceptor;
20
21/// Largest single command line the server will buffer before refusing the peer.
22///
23/// RFC 2449 §4 fixes the POP3 command line at **255 octets** including the CRLF, and RFC 1939
24/// keeps every keyword to three or four characters. 1 KiB is four times the spec's own ceiling,
25/// so nothing a conforming client sends is refused, and the margin covers a long `USER` or an
26/// `APOP` digest without inviting anything larger.
27///
28/// The cap exists because `AsyncBufReadExt::read_line` grows its `String` until it finds a
29/// `\n` and bounds nothing: an unauthenticated peer that connects and streams bytes with no
30/// newline made the server allocate without limit, which is a one-connection out-of-memory.
31/// The refusal happens here, before the `pop3_command_received` event is built, so an
32/// oversized line never reaches a prompt.
33pub const MAX_COMMAND_BYTES: usize = 1024;
34
35/// How long to wait for the first command after the greeting has been sent.
36///
37/// **This was 60 seconds — Dovecot's `login_timeout` — and "POP3 is server-speaks-first" was
38/// the wrong reason for it.** The argument read: the peer gets a `+OK` greeting and every real
39/// client answers it with `CAPA`, `USER` or `AUTH` from inside its own connect path, and
40/// Dovecot bounds exactly this pre-authentication phase at a minute. True of every third-party
41/// client, and irrelevant to the peer this server most often has: the greeting is *ours*, and
42/// sending it says nothing about whether the other end will answer it.
43///
44/// The question a first-byte bound actually has to answer is whether NetGet's own client of
45/// this protocol can be connected and silent. POP3's can, and is, by default:
46/// `src/client/pop3/mod.rs` opens the socket, registers its command channel, and then reads
47/// the `+OK` greeting in its read loop. It writes nothing of its own — every byte it puts on
48/// the wire comes from an action — and a client created from the dashboard's
49/// `[ + pop3 client ]` is routed `pop3_connected` → static-with-no-actions and then `*` →
50/// manual (`src/tui/modal/form.rs`). So it connects, reads our greeting, parks it for a
51/// person, and waits for someone to type into `[ send message ]`. At 60 seconds this server
52/// hung up on the operator's own client while they were still looking at it.
53///
54/// Dovecot's number is not wrong for Dovecot — it has no client that parks on a human. 300
55/// seconds is the window a `manual` rule gives a human to answer one event
56/// (`src/state/intercepts.rs`), which is the number this product already uses for how long
57/// someone might take.
58///
59/// What it costs: a stranger holding a socket, a task, an `AppState` row and one of
60/// [`MAX_CONNECTIONS`] slots while saying nothing now gets 300 seconds rather than 60 — a
61/// fivefold rise in how long one idle slot is held, not a removal of the bound. The cap still
62/// holds and a peer over it is still answered [`CONNECTION_CAP_REFUSAL`]. A listener exposed
63/// to strangers should set `first_byte_timeout_secs` back to 60, which is Dovecot's own
64/// number and a sound choice for one.
65///
66/// The greeting itself sits outside this deadline by construction: it is generated and written
67/// before the command loop begins, so however long the model — or a `manual` rule parking it
68/// for a human — takes over it, the clock below has not started.
69const FIRST_COMMAND_READ_TIMEOUT: Duration = Duration::from_secs(300);
70
71/// The two read deadlines for one connection, resolved from the server's startup parameters.
72///
73/// Carried as one value so threading them from `spawn_with_llm_actions` down to the command
74/// loop costs one argument rather than two at each hop.
75#[derive(Clone, Copy)]
76struct ReadDeadlines {
77 /// [`FIRST_COMMAND_READ_TIMEOUT`], or this server's `first_byte_timeout_secs`.
78 first_byte: Duration,
79 /// [`IDLE_BETWEEN_COMMANDS_TIMEOUT`], or this server's `idle_timeout_secs`.
80 idle: Duration,
81}
82
83impl ReadDeadlines {
84 fn resolve(first_byte_secs: Option<u64>, idle_secs: Option<u64>) -> Self {
85 Self {
86 first_byte: first_byte_secs
87 .map(Duration::from_secs)
88 .unwrap_or(FIRST_COMMAND_READ_TIMEOUT),
89 idle: idle_secs
90 .map(Duration::from_secs)
91 .unwrap_or(IDLE_BETWEEN_COMMANDS_TIMEOUT),
92 }
93 }
94}
95
96/// How long to wait for a *further* command once one has been answered.
97///
98/// Ten minutes, which is not a taste: RFC 1939 §3 says a POP3 server's inactivity autologout
99/// timer "MUST be of at least 10 minutes' duration". This is that timer, at its minimum. The
100/// shorter bound above does not contradict it — what the RFC protects is a *session*, whose
101/// deletions are only committed at `QUIT`, and a peer that has issued no command has no session
102/// to lose.
103///
104/// The LLM round-trip and a `manual` rule parking a command for a human
105/// (`src/state/intercepts.rs`, 300s by default) both happen after a line has already been read,
106/// so neither can be timed out from under itself.
107///
108/// Overridable per server with `idle_timeout_secs` — but note that RFC 1939's floor means
109/// lowering it below 600 puts this server outside the specification, which the parameter's own
110/// description says.
111const IDLE_BETWEEN_COMMANDS_TIMEOUT: Duration = Duration::from_secs(600);
112
113/// Concurrent connections this server admits.
114///
115/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. Each connection may buffer up to
116/// [`MAX_COMMAND_BYTES`], so this is the multiplier that turns that per-connection bound into a
117/// total one.
118const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
119
120/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
121///
122/// `-ERR` in place of the greeting is how POP3 refuses a connection it will not serve, and
123/// RFC 3206's `[SYS/TEMP]` response code says precisely what a connection cap means: a
124/// temporary condition, retry later. A client that understands it backs off instead of
125/// recording a permanent fault; one that does not still reads a well-formed `-ERR`. This file
126/// already uses the `[SYS/PERM]` half of the same pair for an over-long command line.
127const CONNECTION_CAP_REFUSAL: &[u8] = b"-ERR [SYS/TEMP] too many connections\r\n";
128
129/// POP3 server that forwards mail retrieval to LLM
130/// NetGet's answer to `CAPA` (RFC 2449).
131///
132/// Answered here rather than by the model because it has one correct form: the commands this
133/// server has actions for (`USER`/`PASS`, `TOP`, `UIDL`) and the extended response codes its own
134/// failure replies carry (`RESP-CODES` - `[SYS/TEMP]`, `[SYS/PERM]`). It is multi-line, and no
135/// model action renders a multi-line `+OK` block other than the message and listing actions, so
136/// a model asked for it answered a single `+OK` and left the client waiting for a `.` that never
137/// came. Nothing else is advertised: no STLS, no SASL, no PIPELINING, no APOP.
138pub const CAPA_REPLY: &[u8] =
139 b"+OK Capability list follows\r\nUSER\r\nTOP\r\nUIDL\r\nRESP-CODES\r\n.\r\n";
140
141/// The actions whose reply wins when a batch answers one POP3 command with several: the one
142/// the command's `answer_with` names when only one fits. The real-model eval found the model
143/// answering `STAT` with a bare `+OK` *and then* the `STAT` line; the second is the answer.
144/// See `ExecutionResult::chosen_reply`.
145pub fn pop3_preferred_actions(command: &str) -> &'static [&'static str] {
146 if command == "CONNECTION_ESTABLISHED" {
147 return &["send_pop3_greeting"];
148 }
149 let mut words = command.split_whitespace();
150 let verb = words.next().unwrap_or("").to_ascii_uppercase();
151 let has_arg = words.next().is_some();
152 match (verb.as_str(), has_arg) {
153 ("STAT", _) => &["send_pop3_stat"],
154 ("LIST", false) => &["send_pop3_list"],
155 ("UIDL", false) => &["send_pop3_uidl"],
156 ("RETR", _) => &["send_pop3_retr", "send_pop3_err"],
157 ("TOP", _) => &["send_pop3_top", "send_pop3_err"],
158 _ => &[],
159 }
160}
161
162/// A `RETR`/`TOP` answered as a `+OK` status line and the message as raw text, framed by NetGet.
163///
164/// The real-model eval found llama3.1:8b answering `RETR` with `send_pop3_ok` followed by
165/// `send_pop3_message` carrying the message itself - the right content, split the way the wire
166/// shows it, with no terminating `.`, so the client waited for one until its timeout. That is a
167/// multi-line answer with its mechanics missing, and the mechanics are NetGet's to write: the
168/// status line, the byte-stuffed body and the `.` line, exactly as `send_pop3_retr` renders
169/// them. Returns `None` - leaving the batch alone - unless the command is `RETR`/`TOP`, the
170/// batch has no `send_pop3_retr`/`send_pop3_top`/`send_pop3_err` of its own, and a
171/// `send_pop3_message` in it is not itself a status line.
172pub fn assemble_multiline_answer(
173 command: &str,
174 result: &crate::llm::actions::executor::ExecutionResult,
175) -> Option<Vec<u8>> {
176 if command != "RETR" && command != "TOP" {
177 return None;
178 }
179 let names = &result.protocol_result_actions;
180 if names.iter().any(|n| {
181 matches!(
182 n.as_str(),
183 "send_pop3_retr" | "send_pop3_top" | "send_pop3_err"
184 )
185 }) {
186 return None;
187 }
188 let body = names
189 .iter()
190 .zip(&result.protocol_results)
191 .find_map(|(name, r)| {
192 if name != "send_pop3_message" {
193 return None;
194 }
195 let text = String::from_utf8_lossy(r.get_all_output().first()?).into_owned();
196 (!text.starts_with("+OK") && !text.starts_with("-ERR")).then_some(text)
197 })?;
198 // A terminator the model wrote itself is not message text.
199 let body = body.replace("\r\n", "\n");
200 let body = body
201 .trim_end_matches('\n')
202 .strip_suffix("\n.")
203 .unwrap_or(body.trim_end_matches('\n'));
204 let framed = actions::format_pop3_multiline_body(body);
205 Some(format!("+OK {} octets\r\n{}.\r\n", framed.len(), framed).into_bytes())
206}
207
208/// The event data for one POP3 command line: the line itself, plus what NetGet can tell from
209/// it.
210///
211/// `answer_with` names the action this particular command takes. The real-model eval found a
212/// small model answering `RETR` with a bare `send_pop3_ok` - a single `+OK` line, after which
213/// the client waits for the message body and its terminating `.` until its own timeout. A
214/// per-request hint is followed far better than the general action list. `message_number`
215/// (and `lines`, for `TOP`) are the command's arguments as numbers.
216pub fn pop3_command_event_data(command: &str) -> serde_json::Value {
217 let mut data = serde_json::json!({ "command": command });
218 if command == "CONNECTION_ESTABLISHED" {
219 data["answer_with"] = "send_pop3_greeting".into();
220 return data;
221 }
222 let mut words = command.split_whitespace();
223 let verb = words.next().unwrap_or("").to_ascii_uppercase();
224 let n: Option<u64> = words.next().and_then(|w| w.parse().ok());
225 let lines: Option<u64> = words.next().and_then(|w| w.parse().ok());
226 if let Some(n) = n {
227 data["message_number"] = n.into();
228 }
229 let answer = match (verb.as_str(), n) {
230 ("USER", _) => "send_pop3_ok to accept the user name (the password follows), or \
231 send_pop3_err to refuse it"
232 .to_string(),
233 ("PASS", _) => {
234 "send_pop3_ok to log in, or send_pop3_err to refuse the password".to_string()
235 }
236 ("STAT", _) => "send_pop3_stat with message_count and total_size (octets) for the whole \
237 mailbox"
238 .to_string(),
239 ("LIST", None) => "send_pop3_list with the id and size of every message".to_string(),
240 ("LIST", Some(n)) => format!(
241 "send_pop3_ok with message \"{n} <size in octets>\" for message {n}, or \
242 send_pop3_err if there is no message {n}"
243 ),
244 ("UIDL", None) => "send_pop3_uidl with the id and unique id of every message".to_string(),
245 ("UIDL", Some(n)) => format!(
246 "send_pop3_ok with message \"{n} <unique id>\" for message {n}, or send_pop3_err \
247 if there is no message {n}"
248 ),
249 ("RETR", Some(n)) => format!(
250 "send_pop3_retr with the whole of message {n}: its from, to, subject and body \
251 (NetGet writes the header lines, the octet count and the terminating '.'); \
252 send_pop3_err if there is no message {n}. Never a bare send_pop3_ok: the client \
253 waits for the message itself"
254 ),
255 ("TOP", Some(n)) => {
256 if let Some(lines) = lines {
257 data["lines"] = lines.into();
258 }
259 format!(
260 "send_pop3_top with message {n}'s headers and the first {} body lines; \
261 send_pop3_err if there is no message {n}",
262 lines.map_or_else(|| "requested".to_string(), |l| l.to_string())
263 )
264 }
265 ("DELE", Some(n)) => format!(
266 "send_pop3_ok to mark message {n} deleted, or send_pop3_err if there is no message {n}"
267 ),
268 ("NOOP", _) | ("RSET", _) => "send_pop3_ok".to_string(),
269 ("QUIT", _) => "send_pop3_ok, then close_connection".to_string(),
270 _ => return data,
271 };
272 data["answer_with"] = answer.into();
273 data
274}
275
276pub struct Pop3Server;
277
278impl Pop3Server {
279 /// Spawn POP3 server with integrated LLM actions
280 ///
281 /// If tls_config is Some, the server will use implicit TLS (POP3S)
282 /// If tls_config is None, the server will use plain text (POP3)
283 #[allow(clippy::too_many_arguments)]
284 pub async fn spawn_with_llm_actions(
285 listen_addr: SocketAddr,
286 llm_client: OllamaClient,
287 app_state: Arc<AppState>,
288 status_tx: mpsc::UnboundedSender<String>,
289 server_id: crate::state::ServerId,
290 tls_config: Option<Arc<rustls::ServerConfig>>,
291 first_byte_timeout_secs: Option<u64>,
292 idle_timeout_secs: Option<u64>,
293 ) -> Result<SocketAddr> {
294 // Both bounds are tunable because their right value is a property of who is on the
295 // other end, which only the operator knows. The defaults serve NetGet's own POP3
296 // client waiting on a human; a listener exposed to strangers wants the first one much
297 // lower.
298 let deadlines = ReadDeadlines::resolve(first_byte_timeout_secs, idle_timeout_secs);
299 let listener =
300 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
301 let local_addr = listener.local_addr()?;
302
303 if tls_config.is_some() {
304 info!(
305 "POP3S server (TLS, action-based) listening on {}",
306 local_addr
307 );
308 } else {
309 info!(
310 "POP3 server (plain, action-based) listening on {}",
311 local_addr
312 );
313 }
314
315 let protocol = Arc::new(Pop3Protocol::new());
316 let tls_acceptor = tls_config.map(TlsAcceptor::from);
317
318 let task_registrar = app_state.clone();
319 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
320 let accept_handle = tokio::spawn(async move {
321 loop {
322 match crate::server::accept_bounded::accept_bounded(
323 &listener,
324 &limiter,
325 CONNECTION_CAP_REFUSAL,
326 "POP3",
327 Some(&status_tx),
328 )
329 .await
330 {
331 Ok((stream, remote_addr, permit)) => {
332 let connection_id = crate::server::connection::ConnectionId::new(
333 app_state.get_next_unified_id().await,
334 );
335 // Captured before a TLS handshake could consume the TcpStream.
336 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
337 console_debug!(
338 status_tx,
339 "POP3 connection {} from {}",
340 connection_id,
341 remote_addr
342 );
343
344 let llm_clone = llm_client.clone();
345 let state_clone = app_state.clone();
346 let status_clone = status_tx.clone();
347 let protocol_clone = protocol.clone();
348 let tls_acceptor_clone = tls_acceptor.clone();
349
350 // Tracked, not detached: stop_server must abort this task too.
351 let task_owner = app_state.clone();
352 task_owner
353 .spawn_server_task(server_id, async move {
354 // Held for the life of the connection: releasing it here would
355 // cap the accept rate rather than the number of live
356 // connections.
357 let _permit = permit;
358 // Optionally perform TLS handshake
359 if let Some(ref acceptor) = tls_acceptor_clone {
360 match acceptor.accept(stream).await {
361 Ok(tls_stream) => {
362 debug!(
363 "TLS handshake completed for connection {}",
364 connection_id
365 );
366 let _ = status_clone.send(format!(
367 "[DEBUG] TLS handshake completed for connection {}",
368 connection_id
369 ));
370 if let Err(e) = Pop3Session::handle_session(
371 tls_stream,
372 connection_id,
373 remote_addr,
374 local_addr_conn,
375 server_id,
376 llm_clone,
377 state_clone,
378 status_clone,
379 protocol_clone,
380 deadlines,
381 )
382 .await
383 {
384 error!(
385 "POP3S session error for connection {}: {}",
386 connection_id, e
387 );
388 }
389 }
390 Err(e) => {
391 error!(
392 "TLS handshake failed for connection {}: {}",
393 connection_id, e
394 );
395 }
396 }
397 } else {
398 // Plain text POP3
399 if let Err(e) = Pop3Session::handle_session(
400 stream,
401 connection_id,
402 remote_addr,
403 local_addr_conn,
404 server_id,
405 llm_clone,
406 state_clone,
407 status_clone,
408 protocol_clone,
409 deadlines,
410 )
411 .await
412 {
413 error!(
414 "POP3 session error for connection {}: {}",
415 connection_id, e
416 );
417 }
418 }
419 })
420 .await;
421 }
422 Err(e) => {
423 // Do not continue: an accept() error here is persistent (the listener
424 // is gone, or the process is out of descriptors), and looping on it
425 // spins a core at 100% while the server still reports Running.
426 error!("Failed to accept POP3 connection, stopping listener: {}", e);
427 let _ = status_tx.send(format!(
428 "[ERROR] POP3 listener stopped, failed to accept connection: {}",
429 e
430 ));
431 break;
432 }
433 }
434 }
435 });
436
437 task_registrar
438 .register_server_task(server_id, accept_handle)
439 .await;
440
441 Ok(local_addr)
442 }
443}
444
445struct Pop3Session;
446
447impl Pop3Session {
448 /// Drive one POP3 session to completion.
449 ///
450 /// Generic over the transport so the plain TCP and POP3S (implicit TLS) paths share a
451 /// single implementation - they were previously duplicated verbatim, which is how the
452 /// two drifted.
453 #[allow(clippy::too_many_arguments)]
454 async fn handle_session<S>(
455 stream: S,
456 connection_id: crate::server::connection::ConnectionId,
457 remote_addr: SocketAddr,
458 local_addr: SocketAddr,
459 server_id: crate::state::ServerId,
460 llm_client: OllamaClient,
461 app_state: Arc<AppState>,
462 status_tx: mpsc::UnboundedSender<String>,
463 protocol: Arc<Pop3Protocol>,
464 deadlines: ReadDeadlines,
465 ) -> Result<()>
466 where
467 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
468 {
469 use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
470
471 let (read_half, write_half) = tokio::io::split(stream);
472 let reader = tokio::io::BufReader::new(read_half);
473 let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
474
475 // Track the connection so the dashboard lists it with live counters.
476 let now = crate::utils::clock::Instant::now();
477 app_state
478 .add_connection_to_server(
479 server_id,
480 ConnectionState {
481 id: connection_id,
482 remote_addr,
483 local_addr,
484 bytes_sent: 0,
485 bytes_received: 0,
486 packets_sent: 0,
487 packets_received: 0,
488 last_activity: now,
489 status: ConnectionStatus::Active,
490 status_changed_at: now,
491 protocol_info: ProtocolConnectionInfo::empty(),
492 },
493 )
494 .await;
495 let _ = status_tx.send("__UPDATE_UI__".to_string());
496
497 // Peer messaging: the dashboard's "message this peer" / "disconnect this peer" inject
498 // actions into THIS connection through the same executor the LLM path uses. Registered
499 // before the greeting, because a manual `*` rule can park that greeting for minutes and
500 // the operator must be able to reach the connection while it waits.
501 let peer_rx = crate::server::peer_support::register_peer_channel(
502 &app_state,
503 server_id,
504 connection_id.as_u32(),
505 )
506 .await;
507 crate::server::peer_support::spawn_peer_command_task(
508 peer_rx,
509 protocol.clone(),
510 app_state.clone(),
511 server_id,
512 connection_id.as_u32(),
513 write_half.clone(),
514 status_tx.clone(),
515 );
516
517 let result = Self::run_session(
518 reader,
519 &write_half,
520 connection_id,
521 server_id,
522 &llm_client,
523 &app_state,
524 &status_tx,
525 &protocol,
526 deadlines,
527 )
528 .await;
529
530 // Every exit path - EOF, read error, close_connection, refused greeting - lands here.
531 app_state
532 .remove_peer_handle(server_id, connection_id.as_u32())
533 .await;
534 app_state
535 .close_connection_on_server(server_id, connection_id)
536 .await;
537 let _ = status_tx.send("__UPDATE_UI__".to_string());
538 result
539 }
540
541 #[allow(clippy::too_many_arguments)]
542 async fn run_session<R, W>(
543 mut reader: tokio::io::BufReader<R>,
544 write_half: &Arc<tokio::sync::Mutex<W>>,
545 connection_id: crate::server::connection::ConnectionId,
546 server_id: crate::state::ServerId,
547 llm_client: &OllamaClient,
548 app_state: &Arc<AppState>,
549 status_tx: &mpsc::UnboundedSender<String>,
550 protocol: &Arc<Pop3Protocol>,
551 deadlines: ReadDeadlines,
552 ) -> Result<()>
553 where
554 R: tokio::io::AsyncRead + Unpin,
555 W: tokio::io::AsyncWrite + Unpin,
556 {
557 use tokio::io::AsyncWriteExt;
558
559 // Send initial greeting
560 let mut greeting_data = pop3_command_event_data("CONNECTION_ESTABLISHED");
561 greeting_data["connection_id"] = connection_id.to_string().into();
562 let greeting_event = Event::new(&POP3_COMMAND_EVENT, greeting_data);
563
564 match Self::process_command(
565 &greeting_event,
566 llm_client,
567 app_state,
568 status_tx,
569 protocol,
570 server_id,
571 connection_id,
572 write_half,
573 )
574 .await
575 {
576 Ok(SessionControl::Close) => return Ok(()),
577 Ok(SessionControl::Continue) => {}
578 Err(e) => {
579 // A POP3 client waits for a banner before saying anything, so dropping the
580 // socket here left it blocked until its own timeout. RFC 1939 allows the
581 // greeting to be `-ERR`, and RFC 2449 gives the reason a machine-readable code.
582 let token = if crate::utils::WireFailure::classify(&e).is_overloaded() {
583 "fail_closed_llm_overloaded"
584 } else {
585 "fail_closed_llm_error"
586 };
587 error!(
588 "POP3 greeting on connection {} decision={}: {}",
589 connection_id, token, e
590 );
591 let _ = status_tx.send(format!(
592 "[ERROR] POP3 greeting on connection {} decision={}",
593 connection_id, token
594 ));
595 let reply = pop3_failure_reply(&e);
596 let _ = status_tx.send(format!(
597 "[ERROR] POP3 connection {} refused: {}",
598 connection_id,
599 reply.trim_end()
600 ));
601 let mut writer = write_half.lock().await;
602 let _ = writer.write_all(reply.as_bytes()).await;
603 let _ = writer.flush().await;
604 drop(writer);
605 app_state
606 .update_connection_stats(
607 server_id,
608 connection_id,
609 None,
610 Some(reply.len() as u64),
611 None,
612 Some(1),
613 )
614 .await;
615 return Ok(());
616 }
617 }
618
619 // Main command loop
620 let mut answered_one = false;
621 loop {
622 // Bounded: `read_line` would grow its `String` until it found a `\n`, so a peer
623 // that connects and never sends one was a one-connection OOM before any model
624 // call. `line.clear()` bounded accumulation *across* commands and nothing within
625 // one.
626 // The deadline wraps this read and nothing else. Everything that can legitimately
627 // take minutes — the LLM round-trip, and a `manual` rule parking the command for a
628 // human to answer — happens below, after a line has already been read.
629 let read_timeout = if answered_one {
630 deadlines.idle
631 } else {
632 deadlines.first_byte
633 };
634 let framed = tokio::time::timeout(
635 read_timeout,
636 crate::utils::line_reader::read_bounded_line(&mut reader, MAX_COMMAND_BYTES),
637 )
638 .await;
639 let (read, n) = match framed {
640 Err(_) => {
641 info!(
642 "POP3 connection {} sent nothing for {}s; closing idle connection",
643 connection_id,
644 read_timeout.as_secs()
645 );
646 break;
647 }
648 Ok(Ok(v)) => v,
649 Ok(Err(e)) => {
650 debug!("POP3 read error on connection {}: {}", connection_id, e);
651 break;
652 }
653 };
654 answered_one = true;
655 let line = match read {
656 crate::utils::line_reader::BoundedLine::Line(line) => line,
657 crate::utils::line_reader::BoundedLine::Eof => {
658 debug!("POP3 connection {} closed by client", connection_id);
659 break;
660 }
661 crate::utils::line_reader::BoundedLine::TooLong => {
662 // No resynchronisation point: the peer is mid-line, so the next byte is
663 // not the start of a command. Refuse in POP3's own vocabulary and close.
664 // `[SYS/PERM]` rather than `[SYS/TEMP]` — retrying an oversized line
665 // unchanged will fail again.
666 let reply = format!(
667 "-ERR [SYS/PERM] command line exceeds {MAX_COMMAND_BYTES} bytes\r\n"
668 );
669 error!(
670 "POP3 connection {} sent a command line over {} bytes with no newline; \
671 decision=fail_closed_oversized_command",
672 connection_id, MAX_COMMAND_BYTES
673 );
674 let _ = status_tx.send(format!(
675 "[ERROR] POP3 connection {} refused: {}",
676 connection_id,
677 reply.trim_end()
678 ));
679 {
680 let mut writer = write_half.lock().await;
681 let _ = writer.write_all(reply.as_bytes()).await;
682 let _ = writer.flush().await;
683 }
684 app_state
685 .update_connection_stats(
686 server_id,
687 connection_id,
688 Some(n as u64),
689 Some(reply.len() as u64),
690 None,
691 Some(1),
692 )
693 .await;
694 break;
695 }
696 };
697 app_state
698 .update_connection_stats(
699 server_id,
700 connection_id,
701 Some(n as u64),
702 None,
703 Some(1),
704 None,
705 )
706 .await;
707 let command = line.trim().to_string();
708 if command.is_empty() {
709 continue;
710 }
711 // The verb alone, for log lines. Kept separately so nothing downstream
712 // has to re-derive it from a `command` the event may have consumed.
713 let verb = command
714 .split_whitespace()
715 .next()
716 .unwrap_or("?")
717 .to_uppercase();
718
719 console_debug!(
720 status_tx,
721 "POP3 connection {} received: {}",
722 connection_id,
723 command
724 );
725
726 if verb == "CAPA" {
727 info!(
728 "POP3 CAPA on connection {} decision=netget_answer: answered by NetGet",
729 connection_id
730 );
731 let _ = status_tx.send(format!(
732 "[INFO] POP3 CAPA on connection {} decision=netget_answer",
733 connection_id
734 ));
735 {
736 let mut writer = write_half.lock().await;
737 writer.write_all(CAPA_REPLY).await?;
738 writer.flush().await?;
739 }
740 app_state
741 .update_connection_stats(
742 server_id,
743 connection_id,
744 None,
745 Some(CAPA_REPLY.len() as u64),
746 None,
747 Some(1),
748 )
749 .await;
750 continue;
751 }
752
753 let mut event_data = pop3_command_event_data(&command);
754 event_data["connection_id"] = connection_id.to_string().into();
755 let event = Event::new(&POP3_COMMAND_EVENT, event_data);
756
757 match Self::process_command(
758 &event,
759 llm_client,
760 app_state,
761 status_tx,
762 protocol,
763 server_id,
764 connection_id,
765 write_half,
766 )
767 .await
768 {
769 Ok(SessionControl::Continue) => {}
770 Ok(SessionControl::Close) => {
771 debug!("POP3 connection {} closed by server", connection_id);
772 break;
773 }
774 Err(e) => {
775 // Answer before hanging up. Silence here is indistinguishable from
776 // a hung server, and for USER/PASS it is worse than that: the
777 // client cannot tell a refused login from a lost connection.
778 // `-ERR` is a refusal on every command POP3 has, so this fails
779 // closed by construction - there is no `+OK` on this path.
780 // `-ERR`, always. A backend outage and a model that denied the
781 // login are indistinguishable on this wire, so the token is
782 // what keeps them apart in the log.
783 let token = if crate::utils::WireFailure::classify(&e).is_overloaded() {
784 "fail_closed_llm_overloaded"
785 } else {
786 "fail_closed_llm_error"
787 };
788 error!(
789 "POP3 {} on connection {} decision={}: {}",
790 verb, connection_id, token, e
791 );
792 let _ = status_tx.send(format!(
793 "[ERROR] POP3 {} on connection {} decision={}",
794 verb, connection_id, token
795 ));
796 let reply = pop3_failure_reply(&e);
797 let _ = status_tx.send(format!(
798 "[ERROR] POP3 connection {} replying: {}",
799 connection_id,
800 reply.trim_end()
801 ));
802 let mut writer = write_half.lock().await;
803 let _ = writer.write_all(reply.as_bytes()).await;
804 let _ = writer.flush().await;
805 drop(writer);
806 app_state
807 .update_connection_stats(
808 server_id,
809 connection_id,
810 None,
811 Some(reply.len() as u64),
812 None,
813 Some(1),
814 )
815 .await;
816 break;
817 }
818 }
819 }
820
821 Ok(())
822 }
823
824 async fn process_command<W>(
825 event: &Event,
826 llm_client: &OllamaClient,
827 app_state: &Arc<AppState>,
828 status_tx: &mpsc::UnboundedSender<String>,
829 protocol: &Arc<Pop3Protocol>,
830 server_id: crate::state::ServerId,
831 connection_id: crate::server::connection::ConnectionId,
832 write_half: &Arc<tokio::sync::Mutex<W>>,
833 ) -> Result<SessionControl>
834 where
835 W: tokio::io::AsyncWrite + Unpin,
836 {
837 use tokio::io::AsyncWriteExt;
838
839 // The command this answer belongs to, for the decision line. Without it a
840 // `decision=` tag has no subject, and "which command was refused" is the only
841 // question worth asking of a POP3 log.
842 let command = event
843 .data
844 .get("command")
845 .and_then(|v| v.as_str())
846 .unwrap_or("?")
847 .split_whitespace()
848 .next()
849 .unwrap_or("?")
850 .to_uppercase();
851
852 // Call LLM for action.
853 //
854 // The `?` hands an LLM failure back to `run_session`, which answers `-ERR` and
855 // logs `decision=fail_closed_llm_*`. **Nothing on that path can produce `+OK`**:
856 // the only `+OK` in this protocol comes from an action the model named, and
857 // `pop3_failure_reply` is `-ERR` in every branch. That is what stops a backend
858 // outage from becoming a granted mailbox — the OAuth2 failure mode.
859 let llm_result = call_llm(
860 llm_client,
861 app_state,
862 server_id,
863 Some(connection_id),
864 event,
865 protocol.as_ref(),
866 )
867 .await?;
868 let failures = llm_result.failures.len();
869 let mut llm_result = llm_result;
870 if let Some(assembled) = assemble_multiline_answer(&command, &llm_result) {
871 info!(
872 "POP3 {} on connection {} decision=multiline_assembled: a +OK status line and \
873 the message as raw text, framed by NetGet",
874 command, connection_id
875 );
876 let _ = status_tx.send(format!(
877 "[INFO] POP3 {} on connection {} decision=multiline_assembled",
878 command, connection_id
879 ));
880 llm_result.protocol_results = vec![ActionResult::Output(assembled)];
881 llm_result.protocol_result_actions = vec!["send_pop3_retr".to_string()];
882 }
883 let chosen = llm_result.chosen_reply(pop3_preferred_actions(
884 event
885 .data
886 .get("command")
887 .and_then(|v| v.as_str())
888 .unwrap_or(""),
889 ));
890
891 // Execute actions. `close_connection` must still flush everything queued before it -
892 // the QUIT reply is normally `send_pop3_ok` followed by `close_connection` in the same
893 // batch - so record the intent and act on it once the batch is drained.
894 let mut control = SessionControl::Continue;
895 // What the model actually put on the wire. POP3 says yes and no with the same
896 // action mechanism, so the leading token of the first reply is the only honest
897 // way to tell an approval from a refusal — and they must never be conflated.
898 let mut first_reply: Option<String> = None;
899 let mut asked_to_close = false;
900 let mut dropped_replies = 0usize;
901
902 for (index, action) in llm_result.protocol_results.into_iter().enumerate() {
903 match action {
904 // One command, one reply. POP3 has no pipelining here, so a second reply is
905 // read as the answer to the client's *next* command, and every reply after it
906 // is one step late - a RETR then reads the previous command's `+OK` as its
907 // status line and waits for a body that is never coming. Only the chosen one -
908 // the action `answer_with` names, else the first - is written.
909 ActionResult::Output(_) if Some(index) != chosen => dropped_replies += 1,
910 ActionResult::Output(data) => {
911 first_reply =
912 Some(String::from_utf8_lossy(&data[..data.len().min(8)]).to_string());
913 let mut writer = write_half.lock().await;
914 writer.write_all(&data).await?;
915 writer.flush().await?;
916 drop(writer);
917 app_state
918 .update_connection_stats(
919 server_id,
920 connection_id,
921 None,
922 Some(data.len() as u64),
923 None,
924 Some(1),
925 )
926 .await;
927
928 console_debug!(
929 status_tx,
930 "POP3 connection {} sent {} bytes",
931 connection_id,
932 data.len()
933 );
934 }
935 ActionResult::CloseConnection => {
936 control = SessionControl::Close;
937 asked_to_close = true;
938 }
939 ActionResult::WaitForMore => {
940 // Do nothing, wait for next command
941 }
942 _ => {
943 // Not an action that produces POP3 output (memory updates, logging, ...)
944 }
945 }
946 }
947
948 if dropped_replies > 0 {
949 tracing::warn!(
950 "POP3 {} on connection {} decision=duplicate_response_dropped: {} extra \
951 repl{} not sent; one command gets one reply",
952 command,
953 connection_id,
954 dropped_replies,
955 if dropped_replies == 1 { "y" } else { "ies" }
956 );
957 let _ = status_tx.send(format!(
958 "[WARN] POP3 {} on connection {} decision=duplicate_response_dropped ({})",
959 command, connection_id, dropped_replies
960 ));
961 }
962
963 // One decision line per command, so `grep decision=` separates a granted request
964 // from a refused one from a request nobody answered.
965 let decision = match first_reply.as_deref() {
966 Some(reply) if reply.starts_with("-ERR") => "model_reject",
967 Some(_) => "model_answer",
968 // A `close_connection` with no reply is the model hanging up rather than
969 // answering — a deliberate refusal, not a failure.
970 None if asked_to_close => "model_reject",
971 None if failures == 0 => "model_silent",
972 None => "fail_closed_bad_action",
973 };
974 let summary = format!(
975 "POP3 {} on connection {} decision={} ({} failed action(s))",
976 command, connection_id, decision, failures
977 );
978 match decision {
979 // Nothing was written and nothing asked to close: the client is still waiting
980 // for a reply it will never get. No `+OK` was produced, so no mailbox was
981 // opened — but the peer is owed an answer it did not receive.
982 "model_silent" => {
983 tracing::warn!("{}", summary);
984 let _ = status_tx.send(format!("[WARN] {}", summary));
985 }
986 "fail_closed_bad_action" => {
987 error!("{}", summary);
988 let _ = status_tx.send(format!("[ERROR] {}", summary));
989 }
990 _ => {
991 info!("{}", summary);
992 let _ = status_tx.send(format!("[INFO] {}", summary));
993 }
994 }
995
996 Ok(control)
997 }
998}
999
1000/// The `-ERR` line to write when the LLM backend fails, RFC 2449 extended response code
1001/// included so the client can tell "come back later" from "this is broken".
1002///
1003/// `[SYS/TEMP]` is the retryable one and is reserved for capacity exhaustion - the same split
1004/// HTTP makes between 503 and 500. Everything else gets `[SYS/PERM]`, which does not invite an
1005/// immediate retry loop against a backend that is down.
1006///
1007/// Every branch is `-ERR`. POP3 has no response that both refuses and looks like success, so
1008/// there is no way for this path to authenticate anybody or hand out a message.
1009fn pop3_failure_reply(err: &anyhow::Error) -> String {
1010 // The text is a category, never the error itself (`crate::utils::wire_failure`). The reply
1011 // is one line, so a newline in an error would have forged a second response, and a leading
1012 // `.` would have terminated a multiline block.
1013 let failure = crate::utils::WireFailure::classify(err);
1014 let text = failure.prefixed_text();
1015 if failure.is_overloaded() {
1016 format!("-ERR [SYS/TEMP] {text}\r\n")
1017 } else {
1018 format!("-ERR [SYS/PERM] {text}\r\n")
1019 }
1020}
1021
1022/// Whether the command loop should keep reading or shut the connection down.
1023///
1024/// `process_command` used to signal a close by returning `Ok(())` early, which is
1025/// indistinguishable from "command handled" - so `close_connection` never actually closed
1026/// anything and a client that sent QUIT was left holding an open socket.
1027#[derive(Clone, Copy, PartialEq, Eq)]
1028enum SessionControl {
1029 Continue,
1030 Close,
1031}