Skip to main content

netget/server/imap/
mod.rs

1//! IMAP server implementation
2//!
3//! This module implements an IMAP (Internet Message Access Protocol) server
4//! that allows LLM control over email retrieval and mailbox management.
5//!
6//! Key points:
7//! - IMAP4rev1 commands parsed by hand into (tag, command, args). There is no `imap-codec`
8//!   and no grammar: anything the split does not model (literals, continuations, quoting) is
9//!   passed to the model as raw text.
10//! - Session state management (NotAuthenticated -> Authenticated -> Selected -> Logout)
11//! - Plain TCP only. An `ImapServer::spawn_with_tls` used to sit here advertising IMAPS on
12//!   993; nothing called it and it could not have worked - it fed a concatenated PEM string
13//!   to `Identity::from_pkcs12`, which only accepts DER PKCS#12 - so it was removed rather
14//!   than left as an implemented-looking feature.
15//! - No mailbox storage: the model answers LIST/FETCH/SEARCH from its instruction and memory.
16
17pub mod actions;
18
19use anyhow::Result;
20use std::net::SocketAddr;
21use std::sync::Arc;
22use tokio::io::{AsyncWriteExt, BufReader};
23use tokio::sync::mpsc;
24use tracing::{debug, error, info, trace, warn};
25
26#[cfg(not(target_arch = "wasm32"))]
27use crate::llm::action_helper::call_llm;
28#[cfg(not(target_arch = "wasm32"))]
29use crate::llm::ollama_client::OllamaClient;
30#[cfg(not(target_arch = "wasm32"))]
31use crate::llm::ActionResult;
32#[cfg(not(target_arch = "wasm32"))]
33use crate::logging::emit::Log;
34#[cfg(not(target_arch = "wasm32"))]
35use crate::protocol::Event;
36#[cfg(not(target_arch = "wasm32"))]
37use crate::server::connection::ConnectionId;
38#[cfg(not(target_arch = "wasm32"))]
39use crate::server::ImapProtocol;
40#[cfg(not(target_arch = "wasm32"))]
41use crate::state::app_state::AppState;
42#[cfg(not(target_arch = "wasm32"))]
43use crate::state::server::{
44    ConnectionStatus, ImapSessionState, ProtocolConnectionInfo, ProtocolState, ServerId,
45};
46#[cfg(not(target_arch = "wasm32"))]
47use actions::{IMAP_AUTH_EVENT, IMAP_CONNECTION_EVENT};
48#[cfg(not(target_arch = "wasm32"))]
49use serde_json::json;
50
51/// Largest single command line the server will buffer before refusing the peer.
52///
53/// IMAP4rev1 sets no line limit of its own, so the reference point is what a real server
54/// chose: Dovecot's `imap_max_line_length` defaults to 64 KiB. 8 KiB is deliberately tighter,
55/// because this server **does not implement literals** (see the module header — a `{n}`
56/// continuation is passed to the model as raw text rather than parsed), so no command reaching
57/// this reader legitimately carries a message body. What is left is tags, mailbox names and
58/// UID sets, and 8 KiB holds a `FETCH` over several hundred of them.
59///
60/// The cap exists because `AsyncBufReadExt::read_line` grows its `String` until it finds a
61/// `\n` and bounds nothing: an unauthenticated peer that connects and streams bytes with no
62/// newline made the server allocate without limit. The refusal happens here, before
63/// `handle_command` builds any event, so an oversized line never reaches a prompt.
64#[cfg(not(target_arch = "wasm32"))]
65pub const MAX_COMMAND_BYTES: usize = 8192;
66
67/// How long to wait for a peer's first command after the greeting has gone out.
68///
69/// The reference point is what a real server chose: Dovecot's `login_timeout` defaults to 60
70/// seconds for a connection that has not got anywhere yet. This bound is tighter in *scope* than
71/// Dovecot's — it ends at the first command rather than at authentication — and the same in
72/// spirit: a peer that has been greeted and has said nothing has begun no session, which is the
73/// state an unauthenticated flood lives in.
74///
75/// The greeting itself is sent before the loop and may involve a model round-trip; that happens
76/// outside this deadline, which wraps the `read` and nothing else.
77#[cfg(not(target_arch = "wasm32"))]
78const FIRST_COMMAND_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
79
80/// How long a peer that has already sent a command may send nothing further.
81///
82/// **This number exists because of `IDLE`.** A client in IDLE (RFC 2177) is legitimately silent
83/// for a long time, waiting for the *server* to speak, and this server's own action examples
84/// advertise `IDLE` in their capability lists — so closing such a client would be a bug, not a
85/// bound. RFC 2177 §3 requires the client to terminate and re-issue IDLE **at least every 29
86/// minutes**, so 29 minutes is the interval this bound must sit above; 35 gives the DONE and the
87/// re-issued `IDLE` room to cross a slow link and still be seen as activity.
88///
89/// It is also far longer than the 300-second default a `manual` rule gives a human to answer,
90/// and the deadline wraps the read alone, so neither a model round-trip nor a parked question is
91/// ever inside it.
92#[cfg(not(target_arch = "wasm32"))]
93const IDLE_BETWEEN_COMMANDS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2100);
94
95/// Concurrent connections this server admits.
96///
97/// Each admitted connection may buffer a command line of up to [`MAX_COMMAND_BYTES`] and holds a
98/// session, a peer-command channel and an `AppState` entry before anything is authenticated, so
99/// the cap is what turns that per-connection bound into a total one.
100#[cfg(not(target_arch = "wasm32"))]
101const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
102
103/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
104///
105/// IMAP's own vocabulary for "this session is over and here is why" is an untagged `BYE`
106/// (RFC 3501 §7.1.5), and RFC 5530's `[UNAVAILABLE]` is the machine-readable reason — the same
107/// pair this server already uses to refuse a connection whose greeting handler failed and to end
108/// one that sent an oversized command line. There is no tag to echo: the peer has sent nothing.
109#[cfg(not(target_arch = "wasm32"))]
110const CONNECTION_CAP_REFUSAL: &[u8] = b"* BYE [UNAVAILABLE] too many connections\r\n";
111
112/// IMAP server that handles mail retrieval with LLM
113pub struct ImapServer;
114
115#[cfg(not(target_arch = "wasm32"))]
116impl ImapServer {
117    /// Spawn IMAP server with integrated LLM actions (plain TCP on port 143)
118    pub async fn spawn_with_llm_actions(
119        listen_addr: SocketAddr,
120        llm_client: OllamaClient,
121        app_state: Arc<AppState>,
122        status_tx: mpsc::UnboundedSender<String>,
123        server_id: ServerId,
124    ) -> Result<SocketAddr> {
125        let listener =
126            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
127        let local_addr = listener.local_addr()?;
128        info!("IMAP server (action-based) listening on {}", local_addr);
129        Log::new(Some(&status_tx)).info(format!("IMAP server listening on {}", local_addr));
130
131        let protocol = Arc::new(ImapProtocol::new());
132
133        let task_registrar = app_state.clone();
134        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
135        let accept_handle = tokio::spawn(async move {
136            loop {
137                match crate::server::accept_bounded::accept_bounded(
138                    &listener,
139                    &limiter,
140                    CONNECTION_CAP_REFUSAL,
141                    "IMAP",
142                    Some(&status_tx),
143                )
144                .await
145                {
146                    Ok((stream, remote_addr, permit)) => {
147                        let connection_id =
148                            ConnectionId::new(app_state.get_next_unified_id().await);
149                        debug!("IMAP connection {} from {}", connection_id, remote_addr);
150                        let _ = status_tx.send(format!(
151                            "→ IMAP connection {} from {}",
152                            connection_id, remote_addr
153                        ));
154
155                        // Track connection in server state
156                        let local_addr = stream.local_addr().unwrap_or(listen_addr);
157                        let (read_half, write_half) = tokio::io::split(stream);
158                        let write_half_arc = Arc::new(tokio::sync::Mutex::new(write_half));
159
160                        // Add connection to app_state
161                        app_state
162                            .add_connection_to_server(
163                                server_id,
164                                crate::state::ConnectionState {
165                                    id: connection_id,
166                                    remote_addr,
167                                    local_addr,
168                                    bytes_sent: 0,
169                                    bytes_received: 0,
170                                    packets_sent: 0,
171                                    packets_received: 0,
172                                    last_activity: crate::utils::clock::Instant::now(),
173                                    status: ConnectionStatus::Active,
174                                    status_changed_at: crate::utils::clock::Instant::now(),
175                                    protocol_info: ProtocolConnectionInfo::empty(),
176                                },
177                            )
178                            .await;
179
180                        // Peer messaging: the dashboard's "message this peer" /
181                        // "disconnect this peer" inject actions into THIS connection through
182                        // the same executor the LLM path uses. Registered before the greeting
183                        // event, because a manual `*` rule can park that greeting for minutes
184                        // and the operator must still be able to reach the connection while it
185                        // waits. Every IMAP wire verb returns `ActionResult::Output` (and
186                        // `close_connection` half-closes), so the generic task covers the whole
187                        // vocabulary with no `Custom` gap.
188                        let peer_rx = crate::server::peer_support::register_peer_channel(
189                            &app_state,
190                            server_id,
191                            connection_id.as_u32(),
192                        )
193                        .await;
194                        crate::server::peer_support::spawn_peer_command_task(
195                            peer_rx,
196                            protocol.clone(),
197                            app_state.clone(),
198                            server_id,
199                            connection_id.as_u32(),
200                            write_half_arc.clone(),
201                            status_tx.clone(),
202                        );
203                        let _ = status_tx.send("__UPDATE_UI__".to_string());
204
205                        let llm_clone = llm_client.clone();
206                        let state_clone = app_state.clone();
207                        let status_clone = status_tx.clone();
208                        let protocol_clone = protocol.clone();
209                        let write_half_for_session = write_half_arc.clone();
210
211                        // Tracked, not detached: stop_server must abort this task too.
212                        let task_owner = app_state.clone();
213                        task_owner
214                            .spawn_server_task(server_id, async move {
215                                // Held for the life of the connection: dropping it early
216                                // releases the slot while the peer is still here, which
217                                // silently un-caps the server.
218                                let _permit = permit;
219
220                                let mut session = ImapSession {
221                                    reader: BufReader::new(read_half),
222                                    writer: write_half_for_session,
223                                    connection_id,
224                                    server_id,
225                                    remote_addr,
226                                    llm_client: llm_clone,
227                                    app_state: state_clone.clone(),
228                                    status_tx: status_clone.clone(),
229                                    protocol: protocol_clone,
230                                    capabilities: DEFAULT_CAPABILITIES.to_string(),
231                                };
232
233                                // Handle IMAP session
234                                if let Err(e) = session.handle().await {
235                                    error!("IMAP session error for {}: {}", connection_id, e);
236                                    Log::new(Some(&status_clone)).error(format!(
237                                        "IMAP session {} error: {}",
238                                        connection_id, e
239                                    ));
240                                }
241
242                                // Every exit path of `handle()` - EOF, read error, refused
243                                // greeting, LOGOUT - lands here, so this single cleanup removes the
244                                // peer handle no matter how the session ended (idempotent with the
245                                // peer task's own removal on an injected close).
246                                state_clone
247                                    .remove_peer_handle(server_id, connection_id.as_u32())
248                                    .await;
249
250                                // Mark connection as closed
251                                state_clone
252                                    .update_connection_status(
253                                        server_id,
254                                        connection_id,
255                                        ConnectionStatus::Closed,
256                                    )
257                                    .await;
258
259                                info!("IMAP connection {} closed", connection_id);
260                                let _ = status_clone
261                                    .send(format!("✗ IMAP connection {} closed", connection_id));
262                            })
263                            .await;
264                    }
265                    Err(e) => {
266                        Log::new(Some(&status_tx))
267                            .error(format!("Failed to accept IMAP connection: {}", e));
268                        break;
269                    }
270                }
271            }
272        });
273
274        task_registrar
275            .register_server_task(server_id, accept_handle)
276            .await;
277
278        Ok(local_addr)
279    }
280}
281
282/// The capability list NetGet answers `CAPABILITY` with when the greeting announced none.
283pub const DEFAULT_CAPABILITIES: &str = "IMAP4rev1";
284
285/// The capability list a greeting announced in its `[CAPABILITY ...]` response code, with
286/// `IMAP4rev1` guaranteed first; `None` when the greeting carried no such code.
287///
288/// That list is the one fact the `CAPABILITY` command reports, so NetGet answers the command
289/// from it rather than asking the model again. Asked, a small model answered `CAPABILITY` with
290/// a bare tagged `OK` - twice, so the second became the answer to `LOGIN` - or with an empty
291/// `[CAPABILITY]` code, after which imaplib refused the server as not IMAP4 compliant. The
292/// operator still decides the list: it is whatever the greeting says.
293pub fn greeting_capabilities(greeting: &[u8]) -> Option<String> {
294    let text = String::from_utf8_lossy(greeting);
295    let start = text.find("[CAPABILITY ")? + "[CAPABILITY ".len();
296    let end = start + text[start..].find(']')?;
297    Some(with_imap4rev1(&text[start..end]))
298}
299
300/// `list` with `IMAP4rev1` first and not repeated. RFC 3501 6.1.1: the list MUST include it,
301/// and imaplib refuses a server whose list does not.
302pub fn with_imap4rev1(list: &str) -> String {
303    let mut caps = vec![DEFAULT_CAPABILITIES.to_string()];
304    caps.extend(
305        list.split_whitespace()
306            .filter(|c| !c.eq_ignore_ascii_case(DEFAULT_CAPABILITIES))
307            .map(str::to_string),
308    );
309    caps.join(" ")
310}
311
312/// Which action answers one IMAP command, or `None` when the command's name does not say.
313///
314/// The real-model eval found a small model answering `SELECT` with a bare tagged `OK` and no
315/// `* n EXISTS`, which is the count every client reads - imaplib's `select()` returns it - and
316/// answering `CAPABILITY` with the completion of a different command. A per-request hint is
317/// followed far better than the general action list.
318pub fn imap_answer_with(tag: &str, command: &str, args: &str) -> Option<String> {
319    let mailbox = args
320        .split_whitespace()
321        .next()
322        .unwrap_or("")
323        .trim_matches('"');
324    Some(match command.to_ascii_uppercase().as_str() {
325        "LOGIN" => format!(
326            "send_imap_response with tag {tag} and status OK to accept the login, or status NO \
327             to refuse it"
328        ),
329        "SELECT" | "EXAMINE" => format!(
330            "send_imap_select with exists = the number of messages in {mailbox} (and recent, \
331             unseen, uidnext when you know them); NetGet adds the tagged OK. If there is no \
332             mailbox {mailbox}, send_imap_response with tag {tag} and status NO"
333        ),
334        "LIST" | "LSUB" => "send_imap_list with one entry for every mailbox your instruction \
335                            names, and only those (its name and the delimiter \"/\"); NetGet \
336                            adds the tagged OK"
337            .to_string(),
338        "STATUS" => format!(
339            "send_imap_status for mailbox {mailbox} with the items the client asked for; \
340             NetGet adds the tagged OK"
341        ),
342        "FETCH" => {
343            "send_imap_fetch, one per message asked for; NetGet adds the tagged OK".to_string()
344        }
345        "SEARCH" => "send_imap_search with the matching message numbers ([] for none); \
346                     NetGet adds the tagged OK"
347            .to_string(),
348        "NOOP" | "CHECK" | "CLOSE" | "EXPUNGE" | "SUBSCRIBE" | "UNSUBSCRIBE" => {
349            format!("send_imap_response with tag {tag} and status OK")
350        }
351        "LOGOUT" => format!(
352            "send_imap_untagged with response_type BYE, then send_imap_response with tag {tag} \
353             and status OK"
354        ),
355        _ => return None,
356    })
357}
358
359#[cfg(not(target_arch = "wasm32"))]
360struct ImapSession<R, W> {
361    reader: BufReader<R>,
362    writer: Arc<tokio::sync::Mutex<W>>,
363    connection_id: ConnectionId,
364    server_id: ServerId,
365    #[allow(dead_code)]
366    remote_addr: SocketAddr,
367    llm_client: OllamaClient,
368    app_state: Arc<AppState>,
369    status_tx: mpsc::UnboundedSender<String>,
370    protocol: Arc<ImapProtocol>,
371    /// The capability list this connection's greeting announced, which is what NetGet answers
372    /// `CAPABILITY` with. [`DEFAULT_CAPABILITIES`] until a greeting names one.
373    capabilities: String,
374}
375
376#[cfg(not(target_arch = "wasm32"))]
377impl<R: tokio::io::AsyncRead + Unpin, W: tokio::io::AsyncWrite + Unpin> ImapSession<R, W> {
378    async fn handle(&mut self) -> Result<()> {
379        // Send greeting via LLM. A failure here used to propagate and drop the socket without
380        // a single byte written, so the client sat waiting for a banner that never arrived.
381        // RFC 3501 lets a server refuse a connection with an untagged BYE; RFC 5530 gives it
382        // a machine-readable reason.
383        if let Err(e) = self.send_greeting().await {
384            let (code, detail) = imap_failure_code(&e);
385            error!(
386                "IMAP greeting handler failed on connection {}: {}",
387                self.connection_id, e
388            );
389            Log::new(Some(&self.status_tx)).error(format!(
390                "IMAP connection {} refused with BYE [{}]: {}",
391                self.connection_id, code, e
392            ));
393            let bye = format!("* BYE [{}] {}\r\n", code, detail);
394            let _ = self.send_response(bye.as_bytes()).await;
395            // `send_response` flushes; returning drops the session and closes the socket.
396            return Ok(());
397        }
398
399        // "Has said nothing at all" and "has gone quiet mid-session" are different claims and
400        // get different deadlines. This flips on the first command actually read.
401        let mut answered_one = false;
402
403        // Main command loop
404        loop {
405            // Bounded in size and in time. In size: `read_line` would grow its `String` until
406            // it found a `\n`, so a peer that connects and never sends one was a
407            // one-connection OOM before any model call. In time: the deadline below wraps this
408            // read and nothing else, so `handle_command`'s model round-trip — or a `manual`
409            // rule parking a command for a human at the dashboard — sits outside it by
410            // construction.
411            let read_deadline = if answered_one {
412                IDLE_BETWEEN_COMMANDS_TIMEOUT
413            } else {
414                FIRST_COMMAND_READ_TIMEOUT
415            };
416            let read_result = match tokio::time::timeout(
417                read_deadline,
418                crate::utils::line_reader::read_bounded_line(&mut self.reader, MAX_COMMAND_BYTES),
419            )
420            .await
421            {
422                Ok(v) => v,
423                Err(_) => {
424                    // An untagged BYE, because IMAP has one and a client that is told why does
425                    // not record a permanent fault. There is no tag to echo: the peer has sent
426                    // nothing to correlate with.
427                    info!(
428                        "IMAP connection {} sent nothing for {}s; closing \
429                         decision=fail_closed_idle_timeout",
430                        self.connection_id,
431                        read_deadline.as_secs()
432                    );
433                    let _ = self
434                        .send_response(b"* BYE [UNAVAILABLE] idle timeout\r\n")
435                        .await;
436                    break;
437                }
438            };
439            let (read, n) = match read_result {
440                Ok(v) => v,
441                Err(e) => {
442                    debug!("IMAP read error on {}: {}", self.connection_id, e);
443                    break;
444                }
445            };
446            let line = match read {
447                crate::utils::line_reader::BoundedLine::Line(line) => line,
448                crate::utils::line_reader::BoundedLine::Eof => {
449                    // EOF - client disconnected
450                    debug!("IMAP client {} disconnected", self.connection_id);
451                    break;
452                }
453                crate::utils::line_reader::BoundedLine::TooLong => {
454                    // There is no tag to echo — the tag is at the *start* of the line and the
455                    // peer is still mid-line, so nothing here can be correlated to a command.
456                    // RFC 3501 §7.1.5 makes an untagged `* BYE` the server's way of ending a
457                    // session unilaterally, and RFC 5530's `[UNAVAILABLE]` says why.
458                    error!(
459                        "IMAP connection {} sent a command line over {} bytes with no newline; \
460                         decision=fail_closed_oversized_command",
461                        self.connection_id, MAX_COMMAND_BYTES
462                    );
463                    Log::new(Some(&self.status_tx)).error(format!(
464                        "IMAP connection {} refused: command line exceeds {} bytes",
465                        self.connection_id, MAX_COMMAND_BYTES
466                    ));
467                    let bye = format!(
468                        "* BYE [UNAVAILABLE] command line exceeds {MAX_COMMAND_BYTES} bytes\r\n"
469                    );
470                    let _ = self.send_response(bye.as_bytes()).await;
471                    break;
472                }
473            };
474            // A complete command line: from here on this peer is mid-session and gets the
475            // longer bound, which is what lets it sit in IDLE.
476            answered_one = true;
477            {
478                trace!(
479                    "IMAP received {} bytes from {}: {}",
480                    n,
481                    self.connection_id,
482                    line.trim()
483                );
484                Log::new(Some(&self.status_tx)).trace(format!("IMAP command: {}", line.trim()));
485
486                // Update bytes received
487                self.app_state
488                    .update_connection_stats(
489                        self.server_id,
490                        self.connection_id,
491                        Some(n as u64),
492                        None,
493                        Some(1),
494                        None,
495                    )
496                    .await;
497
498                // Parse and handle IMAP command
499                if let Err(e) = self.handle_command(&line).await {
500                    // NO, not BAD. BAD means "I did not understand the command", which
501                    // invites the client to give up on the command permanently; a backend
502                    // failure is a refusal to execute a command we understood fine. The
503                    // tag is echoed so the client can correlate, and the RFC 5530 code
504                    // tells it whether retrying is worth anything.
505                    let (code, detail) = imap_failure_code(&e);
506                    error!(
507                        "Error handling IMAP command on connection {}: {}",
508                        self.connection_id, e
509                    );
510                    Log::new(Some(&self.status_tx)).error(format!(
511                        "IMAP connection {} refusing command with NO [{}]: {}",
512                        self.connection_id, code, e
513                    ));
514
515                    let (tag, _, _) = parse_imap_command(&line);
516                    let error_response = format!("{} NO [{}] {}\r\n", tag, code, detail);
517                    let _ = self.send_response(error_response.as_bytes()).await;
518                }
519
520                // Check if session should logout
521                if let Some((session_state, _, _)) = self
522                    .app_state
523                    .get_imap_connection_state(self.server_id, self.connection_id)
524                    .await
525                {
526                    if session_state == ImapSessionState::Logout {
527                        break;
528                    }
529                }
530            }
531        }
532
533        Ok(())
534    }
535
536    async fn send_greeting(&mut self) -> Result<()> {
537        let event = Event::new(&IMAP_CONNECTION_EVENT, json!({}));
538
539        let result = call_llm(
540            &self.llm_client,
541            &self.app_state,
542            self.server_id,
543            Some(self.connection_id),
544            &event,
545            self.protocol.as_ref(),
546        )
547        .await?;
548
549        let mut wrote_greeting = false;
550        let mut dropped_replies = 0usize;
551        for action_result in result.protocol_results {
552            match action_result {
553                // One greeting. Anything after it would be read as the answer to the client's
554                // first command.
555                ActionResult::Output(_) if wrote_greeting => dropped_replies += 1,
556                ActionResult::Output(data) => {
557                    wrote_greeting = true;
558                    if let Some(capabilities) = greeting_capabilities(&data) {
559                        self.capabilities = capabilities;
560                    }
561                    self.send_response(&data).await?;
562                }
563                ActionResult::CloseConnection => {
564                    // Don't close on greeting
565                }
566                _ => {}
567            }
568        }
569        self.log_dropped_replies("greeting", dropped_replies);
570
571        Ok(())
572    }
573
574    /// Log replies a batch carried past the one the command owed, which were not written.
575    fn log_dropped_replies(&self, what: &str, dropped: usize) {
576        if dropped > 0 {
577            Log::new(Some(&self.status_tx)).warn(format!(
578                "IMAP {} on connection {} decision=duplicate_response_dropped: {} extra \
579                 repl{} not sent; the command was already complete",
580                what,
581                self.connection_id,
582                dropped,
583                if dropped == 1 { "y" } else { "ies" }
584            ));
585        }
586    }
587
588    /// `data` with a tagged status line for another tag rewritten to this command's `tag`.
589    ///
590    /// See [`retag_completion`]; the rewrite is logged `decision=completion_retagged`.
591    fn own_tag(&self, data: &[u8], tag: &str, command: &str) -> Vec<u8> {
592        match retag_completion(data, tag) {
593            Some(fixed) => {
594                Log::new(Some(&self.status_tx)).warn(format!(
595                    "IMAP {} {} on connection {} decision=completion_retagged: the model's \
596                     completion {:?} carried another tag",
597                    tag,
598                    command.to_uppercase(),
599                    self.connection_id,
600                    String::from_utf8_lossy(data).trim_end()
601                ));
602                fixed
603            }
604            None => data.to_vec(),
605        }
606    }
607
608    async fn handle_command(&mut self, line: &str) -> Result<()> {
609        let (tag, command, args) = parse_imap_command(line);
610
611        debug!(
612            "IMAP command from {}: tag={}, command={}, args={}",
613            self.connection_id, tag, command, args
614        );
615
616        // Get current session state
617        let (session_state, authenticated_user, selected_mailbox) = self
618            .app_state
619            .get_imap_connection_state(self.server_id, self.connection_id)
620            .await
621            .unwrap_or((ImapSessionState::NotAuthenticated, None, None));
622
623        // Handle LOGIN specially for authentication event
624        if command.to_uppercase() == "LOGIN" {
625            return self.handle_login(&tag, &args).await;
626        }
627
628        // CAPABILITY is answered from the greeting's own list; see `greeting_capabilities`.
629        // It takes no arguments (RFC 3501 6.1.1), so one that carries some is not the command
630        // with the one mechanical answer and goes to the model like any other line.
631        if command.eq_ignore_ascii_case("CAPABILITY") && args.trim().is_empty() {
632            Log::new(Some(&self.status_tx)).info(format!(
633                "IMAP {} CAPABILITY on connection {} decision=netget_answer: {}",
634                tag, self.connection_id, self.capabilities
635            ));
636            let reply = format!(
637                "* CAPABILITY {}\r\n{} OK CAPABILITY completed\r\n",
638                self.capabilities, tag
639            );
640            return self.send_response(reply.as_bytes()).await;
641        }
642
643        // Create event for LLM
644        let mut event_data = json!({
645            "tag": tag,
646            "command": command,
647            "args": args,
648            "session_state": format!("{:?}", session_state),
649            "authenticated_user": authenticated_user,
650            "selected_mailbox": selected_mailbox,
651        });
652        if let Some(answer_with) = imap_answer_with(&tag, &command, &args) {
653            event_data["answer_with"] = answer_with.into();
654        }
655        let event = Event::new(actions::command_event(&command), event_data);
656
657        let result = call_llm(
658            &self.llm_client,
659            &self.app_state,
660            self.server_id,
661            Some(self.connection_id),
662            &event,
663            self.protocol.as_ref(),
664        )
665        .await?;
666
667        // A command completes only when a line carrying the client's own tag
668        // goes out: async-imap, Thunderbird and every other client read until
669        // they see it. Untagged data alone leaves the client blocked forever,
670        // so track what was actually written and guarantee the completion
671        // below rather than trusting the model to have produced it.
672        let mut sent_any_output = false;
673        let mut deferred = false;
674        // Whether the tagged completion the model itself sent said OK. A SELECT the model
675        // refused must not still move the session into `Selected` (see `update_session_state`).
676        let mut tagged_completion_ok = false;
677
678        let mut dropped_replies = 0usize;
679
680        // The untagged data the batch carries, and the one tagged completion. The completion
681        // ends the command, so NetGet writes it last whatever order the model put it in: a
682        // model that answered SELECT with the tagged OK *before* `send_imap_select` would
683        // otherwise end the command before its EXISTS line. A second completion - the common
684        // case was a second tagged OK - would be read by the client as the answer to its
685        // *next* command, which imaplib aborts on ("unexpected tagged response"); it is
686        // dropped.
687        let mut untagged: Vec<Vec<u8>> = Vec::new();
688        let mut completion: Option<Vec<u8>> = None;
689
690        // Execute actions returned by LLM
691        for action_result in result.protocol_results {
692            match action_result {
693                ActionResult::Output(data) => {
694                    let data = self.own_tag(&data, &tag, &command);
695                    let text = String::from_utf8_lossy(&data);
696                    let is_tagged = text.lines().any(|line| {
697                        line.trim_start()
698                            .to_uppercase()
699                            .starts_with(&format!("{} ", tag.to_uppercase()))
700                    });
701                    if !is_tagged {
702                        // The same untagged block twice is one answer repeated, not two: a
703                        // second `* 12 EXISTS` block reads as a second mailbox state.
704                        if untagged.contains(&data) {
705                            dropped_replies += 1;
706                        } else {
707                            untagged.push(data);
708                        }
709                    } else if completion.is_none() {
710                        tagged_completion_ok = tagged_ok_for(&text, &tag);
711                        completion = Some(data);
712                    } else {
713                        dropped_replies += 1;
714                    }
715                }
716                ActionResult::CloseConnection => {
717                    deferred = true;
718                    // Update session state to Logout
719                    self.app_state
720                        .update_imap_session_state(
721                            self.server_id,
722                            self.connection_id,
723                            ImapSessionState::Logout,
724                        )
725                        .await;
726                }
727                ActionResult::WaitForMore => {
728                    deferred = true;
729                    // Mark as accumulating (for multi-line commands like APPEND)
730                    self.app_state
731                        .update_imap_protocol_state(
732                            self.server_id,
733                            self.connection_id,
734                            ProtocolState::Accumulating,
735                        )
736                        .await;
737                }
738                _ => {}
739            }
740        }
741
742        for data in untagged.iter().chain(completion.iter()) {
743            sent_any_output = true;
744            self.send_response(data).await?;
745        }
746        let sent_tagged_completion = completion.is_some();
747        self.log_dropped_replies(&command.to_uppercase(), dropped_replies);
748
749        // Guarantee the tagged completion. Without it the client waits out its
750        // own timeout on a command the server considers finished — the single
751        // most common way an IMAP answer goes wrong, because untagged data
752        // reads like a complete reply to everything except the client.
753        if !sent_tagged_completion && !deferred {
754            let completion = if sent_any_output {
755                // Untagged data went out, so the command did produce its answer;
756                // only the terminator is missing.
757                format!("{} OK {} completed\r\n", tag, command.to_uppercase())
758            } else {
759                // Nothing at all was written: fail closed with a refusal the
760                // client can act on, distinguishable from a real success.
761                warn!(
762                    "IMAP command {} on connection {} produced no response; answering NO",
763                    command, self.connection_id
764                );
765                format!(
766                    "{} NO netget: no response was produced for {}\r\n",
767                    tag,
768                    command.to_uppercase()
769                )
770            };
771            debug!(
772                "IMAP appending missing tagged completion for {}: {}",
773                command,
774                completion.trim_end()
775            );
776            self.send_response(completion.as_bytes()).await?;
777        }
778
779        // Handle state transitions based on command.
780        //
781        // Gated on the command having actually succeeded. This used to run unconditionally, so
782        // a SELECT the model refused with `tag NO` still moved the session to `Selected` and
783        // recorded the mailbox: the client was told no, the server believed yes, and every
784        // later FETCH/STORE operated against a mailbox the model had just declined. A command
785        // still awaiting more data (`deferred`) has not completed at all, so it transitions
786        // nothing either.
787        let command_ok = if deferred {
788            false
789        } else if sent_tagged_completion {
790            tagged_completion_ok
791        } else {
792            // The completion synthesised above: OK when untagged data went out, NO otherwise.
793            sent_any_output
794        };
795        self.update_session_state(&command, &args, command_ok)
796            .await?;
797
798        Ok(())
799    }
800
801    async fn handle_login(&mut self, tag: &str, args: &str) -> Result<()> {
802        // Parse LOGIN username password
803        let parts: Vec<&str> = args.split_whitespace().collect();
804        if parts.len() < 2 {
805            let response = format!("{} BAD LOGIN requires username and password\r\n", tag);
806            return self.send_response(response.as_bytes()).await;
807        }
808
809        let username = parts[0].trim_matches('"');
810        let password = parts[1].trim_matches('"');
811
812        debug!("IMAP LOGIN attempt: username={}", username);
813
814        let event = Event::new(
815            &IMAP_AUTH_EVENT,
816            json!({
817                "tag": tag,
818                "username": username,
819                "password": password,
820                "answer_with": imap_answer_with(tag, "LOGIN", args),
821            }),
822        );
823
824        let result = call_llm(
825            &self.llm_client,
826            &self.app_state,
827            self.server_id,
828            Some(self.connection_id),
829            &event,
830            self.protocol.as_ref(),
831        )
832        .await?;
833
834        // Check if authentication was successful by looking for OK response
835        let mut auth_success = false;
836        let mut completed = false;
837        let mut dropped_replies = 0usize;
838        for action_result in &result.protocol_results {
839            if let ActionResult::Output(data) = action_result {
840                // Nothing after the tagged completion: it would answer the next command.
841                if completed {
842                    dropped_replies += 1;
843                    continue;
844                }
845                let data = self.own_tag(data, tag, "LOGIN");
846                let response = String::from_utf8_lossy(&data);
847                if tagged_ok_for(&response, tag) {
848                    auth_success = true;
849                }
850                completed = response
851                    .lines()
852                    .any(|line| line.split_whitespace().next() == Some(tag));
853                self.send_response(&data).await?;
854            } else if let ActionResult::CloseConnection = action_result {
855                // Authentication failed, close connection
856                self.app_state
857                    .update_imap_session_state(
858                        self.server_id,
859                        self.connection_id,
860                        ImapSessionState::Logout,
861                    )
862                    .await;
863            }
864        }
865
866        self.log_dropped_replies("LOGIN", dropped_replies);
867
868        // If authentication successful, update session state
869        if auth_success {
870            self.app_state
871                .update_imap_connection_state(
872                    self.server_id,
873                    self.connection_id,
874                    Some(ImapSessionState::Authenticated),
875                    Some(Some(username.to_string())),
876                    None,
877                    None,
878                )
879                .await;
880            debug!("IMAP user {} authenticated", username);
881        }
882
883        Ok(())
884    }
885
886    /// Apply the session-state transition a completed command implies.
887    ///
888    /// `command_ok` is whether the tagged completion the client received said OK. Only LOGOUT
889    /// transitions regardless: the client is leaving either way, and refusing to record that
890    /// would leave a dead session marked live. Everything else transitions only on success —
891    /// server-side state must never claim more than the client was told.
892    async fn update_session_state(
893        &mut self,
894        command: &str,
895        args: &str,
896        command_ok: bool,
897    ) -> Result<()> {
898        let cmd_upper = command.to_uppercase();
899
900        if !command_ok && cmd_upper != "LOGOUT" {
901            debug!(
902                "IMAP {} on connection {} did not succeed (decision=no_state_change); \
903                 session state unchanged",
904                cmd_upper, self.connection_id
905            );
906            return Ok(());
907        }
908
909        match cmd_upper.as_str() {
910            "SELECT" => {
911                // Extract mailbox name
912                let mailbox = args.split_whitespace().next().unwrap_or("INBOX");
913                self.app_state
914                    .update_imap_connection_state(
915                        self.server_id,
916                        self.connection_id,
917                        Some(ImapSessionState::Selected),
918                        None,
919                        Some(Some(mailbox.trim_matches('"').to_string())),
920                        Some(false),
921                    )
922                    .await;
923                debug!("IMAP mailbox selected: {}", mailbox);
924            }
925            "EXAMINE" => {
926                // Like SELECT but read-only
927                let mailbox = args.split_whitespace().next().unwrap_or("INBOX");
928                self.app_state
929                    .update_imap_connection_state(
930                        self.server_id,
931                        self.connection_id,
932                        Some(ImapSessionState::Selected),
933                        None,
934                        Some(Some(mailbox.trim_matches('"').to_string())),
935                        Some(true),
936                    )
937                    .await;
938                debug!("IMAP mailbox examined (read-only): {}", mailbox);
939            }
940            "CLOSE" => {
941                // Close selected mailbox, back to Authenticated
942                self.app_state
943                    .update_imap_connection_state(
944                        self.server_id,
945                        self.connection_id,
946                        Some(ImapSessionState::Authenticated),
947                        None,
948                        Some(None),
949                        Some(false),
950                    )
951                    .await;
952                debug!("IMAP mailbox closed");
953            }
954            "LOGOUT" => {
955                self.app_state
956                    .update_imap_session_state(
957                        self.server_id,
958                        self.connection_id,
959                        ImapSessionState::Logout,
960                    )
961                    .await;
962                debug!("IMAP session logout");
963            }
964            _ => {}
965        }
966
967        Ok(())
968    }
969
970    async fn send_response(&mut self, data: &[u8]) -> Result<()> {
971        let mut writer = self.writer.lock().await;
972        writer.write_all(data).await?;
973        writer.flush().await?;
974        drop(writer);
975
976        // Update stats
977        self.app_state
978            .update_connection_stats(
979                self.server_id,
980                self.connection_id,
981                None,
982                Some(data.len() as u64),
983                None,
984                Some(1),
985            )
986            .await;
987
988        trace!("IMAP sent {} bytes to {}", data.len(), self.connection_id);
989        Log::new(Some(&self.status_tx)).trace(format!("IMAP sent {} bytes", data.len()));
990
991        Ok(())
992    }
993}
994
995/// The RFC 5530 response code and human text to use when the LLM backend fails us.
996///
997/// `UNAVAILABLE` is defined as "temporary failure because a subsystem is down" and is the
998/// signal a client should read as "retry later"; it is what capacity exhaustion deserves.
999/// Anything else - the backend refusing, timing out, or answering with something we cannot
1000/// parse - is reported as `SERVERBUG`, which does not invite an immediate retry loop.
1001///
1002/// Both are refusals. Neither can be confused with success, which is the point: an `OK` here
1003/// would tell the client the command was carried out.
1004#[cfg(not(target_arch = "wasm32"))]
1005fn imap_failure_code(err: &anyhow::Error) -> (&'static str, String) {
1006    // The text is a category, never the error itself (`crate::utils::wire_failure`), which
1007    // also makes it structurally impossible for a newline in an error to forge a second
1008    // response line.
1009    let failure = crate::utils::WireFailure::classify(err);
1010    let code = if failure.is_overloaded() {
1011        "UNAVAILABLE"
1012    } else {
1013        "SERVERBUG"
1014    };
1015    (code, failure.prefixed_text().to_string())
1016}
1017
1018/// Does this payload contain the tagged `OK` completion for `tag`?
1019///
1020/// Used to decide whether a LOGIN succeeded, which makes it an authentication check, so it
1021/// parses rather than pattern-matches. The previous `response.contains("{tag} OK")` searched
1022/// the whole payload for that text anywhere: a refusal whose human-readable message quoted
1023/// the phrase - `A001 NO LOGIN failed, expected A001 OK` - authenticated the session, and so
1024/// did an untagged line that happened to contain it. RFC 3501 §7.1 puts the condition in the
1025/// second field of a line whose first field is the tag, and nowhere else.
1026#[cfg(not(target_arch = "wasm32"))]
1027/// A single tagged status line (`<tag> OK|NO|BAD ...`) whose tag is not `tag`, rewritten to
1028/// carry `tag`; `None` for anything else, including a line that already carries it.
1029///
1030/// One command is in flight at a time, so the only tagged response a server can owe is that
1031/// command's completion. llama3.1:8b copied the `A001` of an action example onto a LIST
1032/// completion; written as given, the line reads as a tagged response to a command the client
1033/// never sent, and imaplib aborts on it ("unexpected response"). An untagged (`*`) or
1034/// continuation (`+`) line, and anything that is not one line, is left as it is.
1035pub fn retag_completion(data: &[u8], tag: &str) -> Option<Vec<u8>> {
1036    let text = std::str::from_utf8(data).ok()?;
1037    let line = text
1038        .strip_suffix("\r\n")
1039        .or_else(|| text.strip_suffix('\n'))
1040        .unwrap_or(text);
1041    if line.contains(['\r', '\n']) {
1042        return None;
1043    }
1044    let mut fields = line.splitn(3, ' ');
1045    let first = fields.next()?;
1046    let status = fields.next()?;
1047    if first.is_empty() || first == "*" || first == "+" || first == tag {
1048        return None;
1049    }
1050    if !["OK", "NO", "BAD"]
1051        .iter()
1052        .any(|s| s.eq_ignore_ascii_case(status))
1053    {
1054        return None;
1055    }
1056    Some(
1057        match fields.next() {
1058            Some(rest) => format!("{tag} {status} {rest}\r\n"),
1059            None => format!("{tag} {status}\r\n"),
1060        }
1061        .into_bytes(),
1062    )
1063}
1064
1065fn tagged_ok_for(payload: &str, tag: &str) -> bool {
1066    payload.lines().any(|line| {
1067        let mut fields = line.trim().split_whitespace();
1068        fields.next() == Some(tag) && fields.next().is_some_and(|status| status == "OK")
1069    })
1070}
1071
1072/// Parse IMAP command line into (tag, command, args)
1073#[cfg(not(target_arch = "wasm32"))]
1074fn parse_imap_command(line: &str) -> (String, String, String) {
1075    let trimmed = line.trim();
1076    let parts: Vec<&str> = trimmed.splitn(3, ' ').collect();
1077
1078    match parts.len() {
1079        0 => ("*".to_string(), "".to_string(), "".to_string()),
1080        1 => (parts[0].to_string(), "".to_string(), "".to_string()),
1081        2 => (parts[0].to_string(), parts[1].to_string(), "".to_string()),
1082        _ => (
1083            parts[0].to_string(),
1084            parts[1].to_string(),
1085            parts[2].to_string(),
1086        ),
1087    }
1088}