Skip to main content

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}