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}