Skip to main content

netget/server/imap/
actions.rs

1//! IMAP protocol actions implementation
2//!
3//! This module implements the action system for IMAP (Internet Message Access Protocol).
4//! The LLM controls all IMAP responses through these actions, including:
5//! - Greeting and capability advertisement
6//! - Authentication (LOGIN)
7//! - Mailbox operations (SELECT, LIST, CREATE, DELETE, RENAME, STATUS, EXAMINE)
8//! - Message operations (FETCH, STORE, SEARCH, COPY, EXPUNGE)
9//! - UID-based operations (UID FETCH, UID STORE, UID SEARCH, UID COPY)
10//! - APPEND for adding messages
11
12use crate::llm::actions::{
13    protocol_trait::{ActionResult, Protocol, Server},
14    ActionDefinition, Parameter,
15};
16use crate::protocol::log_template::LogTemplate;
17use crate::protocol::EventType;
18use crate::state::app_state::AppState;
19use anyhow::{Context, Result};
20use serde_json::json;
21use std::sync::LazyLock;
22use tracing::debug;
23
24/// Octets that may legally follow a literal inside an IMAP **server** response.
25///
26/// RFC 3501's response grammar puts a literal either as the last element of a line (so a CR
27/// follows, opening the CRLF), or as one element of a space-separated, parenthesised list (so a
28/// space or a closing paren follows). `msg-att-static`'s `"BODY" section SP nstring` is the case
29/// that matters here — a `BODY[]` literal is followed by ` ` when another attribute comes after
30/// it and by `)` when it is last — and this server's own `send_imap_fetch`, which computes its
31/// counts from `body.len()`, produces exactly those two.
32const LITERAL_FOLLOWERS: &[u8] = b") \r";
33
34/// Refuse a response whose `{N}` literal count disagrees with the octets that follow it.
35///
36/// # Why this is a guard and not a lint
37///
38/// `send_imap_response`'s `response` field relays a **pre-framed, model-authored** response
39/// straight to the socket. RFC 3501 §4.3 makes a client read *exactly* the declared number of
40/// octets, so a count that is wrong by even one desynchronises the connection **permanently**:
41/// the client swallows part of the next line as message body, then reads the remainder as a
42/// response it cannot parse, and every subsequent tag is off by that much. There is no
43/// recovery and no error the server can send afterwards that the client will understand.
44///
45/// It is also invisible to the kind of assertion a test naturally writes. A fixture in this
46/// tree declared `{50}` for a 46-octet body and passed for as long as it existed, because every
47/// assertion was `line.contains("FETCH")` on a **trimmed** string — trimming discards precisely
48/// the framing the RFC cares about. The pcap oracle found it by handing the bytes to
49/// Wireshark's dissector. This function is the same check on our side of the socket, so a
50/// model that miscounts is refused rather than relayed.
51///
52/// # What it checks
53///
54/// Walking the bytes once, and skipping *over* each literal's payload so a `{5}` that happens
55/// to appear inside a message body is not mistaken for a second marker:
56///
57/// * `{N}` immediately followed by CRLF is a literal marker. `{N}` anywhere else is ordinary
58///   text — `A001 OK fetched {46} bytes` is a perfectly good response — and is left alone.
59/// * At least `N` octets must follow. Fewer is a truncated response.
60/// * The octet at `N` must be one of [`LITERAL_FOLLOWERS`]. This is what catches an *over*-count
61///   that is still long enough: `{50}` over a 46-octet body lands mid-`A004`, which is neither a
62///   space, a paren, nor the CR of a CRLF.
63///
64/// A count that is short by a whole element could still land on a legal follower and pass; this
65/// is a guard against the counting mistake models actually make, not a parser.
66pub fn validate_imap_literals(response: &[u8]) -> Result<()> {
67    let mut i = 0usize;
68    while i < response.len() {
69        if response[i] != b'{' {
70            i += 1;
71            continue;
72        }
73        let mut j = i + 1;
74        while j < response.len() && response[j].is_ascii_digit() {
75            j += 1;
76        }
77        // `{}` with no digits, or `{` with no closing brace, is not a literal marker.
78        if j == i + 1 || j >= response.len() || response[j] != b'}' {
79            i += 1;
80            continue;
81        }
82        // A literal marker is `{N}CRLF`. Anything else is text that happens to contain braces.
83        if j + 3 > response.len() || &response[j + 1..j + 3] != b"\r\n" {
84            i += 1;
85            continue;
86        }
87        let digits = std::str::from_utf8(&response[i + 1..j]).unwrap_or("");
88        let Ok(declared) = digits.parse::<usize>() else {
89            // Too many digits to be a real count; nothing on this wire is that big.
90            anyhow::bail!(
91                "IMAP response declares a literal of {{{digits}}}, which is not a usable octet \
92                 count; the client would block reading it"
93            );
94        };
95        let start = j + 3;
96        let available = response.len() - start;
97        if declared > available {
98            anyhow::bail!(
99                "IMAP response declares a literal of {{{declared}}} octets but only {available} \
100                 follow it. RFC 3501 §4.3 makes the client read exactly the declared count, so \
101                 it would block on octets that are never sent. Count the bytes of the literal \
102                 itself — CRLFs included — and declare that number."
103            );
104        }
105        match response.get(start + declared) {
106            None => anyhow::bail!(
107                "IMAP response declares a literal of {{{declared}}} octets that runs to the very \
108                 end, so the line carrying it is never terminated. A literal is an element of a \
109                 line, not the line: follow it with the rest of the line and a CRLF."
110            ),
111            Some(b) if LITERAL_FOLLOWERS.contains(b) => {}
112            Some(b) => anyhow::bail!(
113                "IMAP response declares a literal of {{{declared}}} octets, but octet {declared} \
114                 is followed by {:?} rather than a space, a ')' or the CR of a CRLF — so the \
115                 count does not line up with the response around it. RFC 3501 §4.3 makes the \
116                 client read exactly {declared} octets as data, swallowing the start of what \
117                 comes next and desynchronising the connection for good. Count the literal's \
118                 own bytes, CRLFs included.",
119                *b as char
120            ),
121        }
122        // Skip the payload: a `{5}` inside a message body is data, not a second marker.
123        i = start + declared;
124    }
125    Ok(())
126}
127
128/// IMAP protocol action handler
129pub struct ImapProtocol;
130
131impl ImapProtocol {
132    pub fn new() -> Self {
133        Self
134    }
135
136    fn execute_send_imap_greeting(&self, action: serde_json::Value) -> Result<ActionResult> {
137        let hostname = action
138            .get("hostname")
139            .and_then(|v| v.as_str())
140            .unwrap_or("localhost");
141
142        // IMAP4rev1 is always first: RFC 3501 6.1.1 requires it, and this list is also what
143        // NetGet answers the CAPABILITY command with (`super::greeting_capabilities`).
144        let capabilities = super::with_imap4rev1(
145            &action
146                .get("capabilities")
147                .and_then(|v| v.as_array())
148                .map(|arr| {
149                    arr.iter()
150                        .filter_map(|v| v.as_str())
151                        .collect::<Vec<_>>()
152                        .join(" ")
153                })
154                .unwrap_or_default(),
155        );
156
157        debug!(
158            "IMAP sending greeting: hostname={}, capabilities={}",
159            hostname, capabilities
160        );
161
162        let greeting = format!(
163            "* OK [CAPABILITY {}] {} IMAP4rev1 Service Ready\r\n",
164            capabilities, hostname
165        );
166        Ok(ActionResult::Output(greeting.into_bytes()))
167    }
168
169    fn execute_send_imap_response(&self, action: serde_json::Value) -> Result<ActionResult> {
170        // Support two formats:
171        // 1. Full response string in 'response' field (simple format for mocks/LLM)
172        // 2. Structured fields: tag, status, message, code (detailed format)
173
174        if let Some(response_str) = action.get("response").and_then(|v| v.as_str()) {
175            // Simple format: just send the response as-is
176            debug!("IMAP sending response (simple format): {}", response_str);
177            let mut response = response_str.to_string();
178            if !response.ends_with("\r\n") {
179                response.push_str("\r\n");
180            }
181            return Ok(ActionResult::Output(response.into_bytes()));
182        }
183
184        // Structured format: build response from tag/status/message/code
185        let tag = action
186            .get("tag")
187            .and_then(|v| v.as_str())
188            .context("Missing 'tag' field in send_imap_response (use 'response' field for untagged responses)")?;
189
190        // Required, never defaulted. RFC 3501 §7.1 gives a tagged response no default
191        // condition, and the one this used to assume was `OK` - so a model that emitted a
192        // `tag` and omitted `status` produced `A001 OK`, which `handle_auth` reads as a
193        // successful LOGIN. A forgotten field is not an authentication decision.
194        let status = action
195            .get("status")
196            .and_then(|v| v.as_str())
197            .context("Missing 'status' field in send_imap_response: a tagged response must say OK, NO or BAD explicitly")?;
198
199        // RFC 3501 9 (`resp-text`): the text after the status is at least one character, so
200        // `a1 OK` alone is not a response - imaplib rejects it ("unexpected response") and
201        // aborts the session. A model that leaves `message` out gets a neutral text instead.
202        let message = action
203            .get("message")
204            .and_then(|v| v.as_str())
205            .filter(|m| !m.trim().is_empty())
206            .unwrap_or("completed");
207
208        let code = action.get("code").and_then(|v| v.as_str());
209
210        debug!(
211            "IMAP sending response (structured format): tag={}, status={}, message={}",
212            tag, status, message
213        );
214
215        let response = if let Some(code) = code {
216            format!("{} {} [{}] {}\r\n", tag, status, code, message)
217        } else {
218            format!("{} {} {}\r\n", tag, status, message)
219        };
220
221        Ok(ActionResult::Output(response.into_bytes()))
222    }
223
224    fn execute_send_imap_untagged(&self, action: serde_json::Value) -> Result<ActionResult> {
225        let response_type = action
226            .get("response_type")
227            .and_then(|v| v.as_str())
228            .context("Missing 'response_type' field in send_imap_untagged")?;
229
230        let data = action.get("data").and_then(|v| v.as_str()).unwrap_or("");
231
232        debug!(
233            "IMAP sending untagged response: type={}, data={}",
234            response_type, data
235        );
236
237        let response = if data.is_empty() {
238            format!("* {}\r\n", response_type)
239        } else {
240            format!("* {} {}\r\n", response_type, data)
241        };
242
243        Ok(ActionResult::Output(response.into_bytes()))
244    }
245
246    fn execute_send_imap_list(&self, action: serde_json::Value) -> Result<ActionResult> {
247        let mailboxes = action
248            .get("mailboxes")
249            .and_then(|v| v.as_array())
250            .context("Missing 'mailboxes' array in send_imap_list")?;
251
252        debug!("IMAP sending LIST response: {} mailboxes", mailboxes.len());
253
254        let mut response = Vec::new();
255
256        for mailbox in mailboxes {
257            let name = mailbox
258                .get("name")
259                .and_then(|v| v.as_str())
260                .unwrap_or("INBOX");
261
262            let delimiter = mailbox
263                .get("delimiter")
264                .and_then(|v| v.as_str())
265                .unwrap_or("/");
266
267            let flags = mailbox
268                .get("flags")
269                .and_then(|v| v.as_array())
270                .map(|arr| {
271                    arr.iter()
272                        .filter_map(|v| v.as_str())
273                        .collect::<Vec<_>>()
274                        .join(" ")
275                })
276                .unwrap_or_default();
277
278            let line = if flags.is_empty() {
279                format!("* LIST () \"{}\" \"{}\"\r\n", delimiter, name)
280            } else {
281                format!("* LIST ({}) \"{}\" \"{}\"\r\n", flags, delimiter, name)
282            };
283            response.extend_from_slice(line.as_bytes());
284        }
285
286        Ok(ActionResult::Output(response))
287    }
288
289    fn execute_send_imap_status(&self, action: serde_json::Value) -> Result<ActionResult> {
290        let mailbox = action
291            .get("mailbox")
292            .and_then(|v| v.as_str())
293            .context("Missing 'mailbox' field in send_imap_status")?;
294
295        let status_items = action
296            .get("items")
297            .and_then(|v| v.as_object())
298            .context("Missing 'items' object in send_imap_status")?;
299
300        debug!("IMAP sending STATUS response for mailbox: {}", mailbox);
301
302        let mut items_str = Vec::new();
303        for (key, value) in status_items {
304            items_str.push(format!("{} {}", key.to_uppercase(), value));
305        }
306
307        let response = format!("* STATUS \"{}\" ({})\r\n", mailbox, items_str.join(" "));
308        Ok(ActionResult::Output(response.into_bytes()))
309    }
310
311    fn execute_send_imap_fetch(&self, action: serde_json::Value) -> Result<ActionResult> {
312        // Support two formats:
313        // 1. Structured: {"sequence": N, "data": {"FLAGS": [...], "BODY[]": "..."}}
314        // 2. Simple: {"message_id": N, "body": "...", "flags": [...], ...}
315
316        let sequence = action
317            .get("sequence")
318            .or_else(|| action.get("message_id"))
319            .and_then(|v| v.as_u64())
320            .context("Missing 'sequence' or 'message_id' field in send_imap_fetch")?;
321
322        debug!("IMAP sending FETCH response for message: {}", sequence);
323
324        let mut items = Vec::new();
325
326        // Check if using structured format with "data" object
327        if let Some(data) = action.get("data").and_then(|v| v.as_object()) {
328            // Structured format - use "data" object
329            for (key, value) in data {
330                match key.to_uppercase().as_str() {
331                    "FLAGS" => {
332                        if let Some(flags_arr) = value.as_array() {
333                            let flags: Vec<&str> =
334                                flags_arr.iter().filter_map(|v| v.as_str()).collect();
335                            items.push(format!("FLAGS ({})", flags.join(" ")));
336                        }
337                    }
338                    "UID" => {
339                        if let Some(uid) = value.as_u64() {
340                            items.push(format!("UID {}", uid));
341                        }
342                    }
343                    "RFC822.SIZE" => {
344                        if let Some(size) = value.as_u64() {
345                            items.push(format!("RFC822.SIZE {}", size));
346                        }
347                    }
348                    "BODY[]" | "RFC822" => {
349                        if let Some(body) = value.as_str() {
350                            items.push(format!(
351                                "{} {{{}}}\r\n{}",
352                                key.to_uppercase(),
353                                body.len(),
354                                body
355                            ));
356                        }
357                    }
358                    "ENVELOPE" => {
359                        if let Some(env_str) = value.as_str() {
360                            items.push(format!("ENVELOPE {}", env_str));
361                        }
362                    }
363                    "BODYSTRUCTURE" => {
364                        if let Some(bs_str) = value.as_str() {
365                            items.push(format!("BODYSTRUCTURE {}", bs_str));
366                        }
367                    }
368                    "INTERNALDATE" => {
369                        if let Some(date) = value.as_str() {
370                            items.push(format!("INTERNALDATE \"{}\"", date));
371                        }
372                    }
373                    _ => {
374                        // Handle any other custom items
375                        if let Some(val_str) = value.as_str() {
376                            items.push(format!("{} {}", key.to_uppercase(), val_str));
377                        }
378                    }
379                }
380            }
381        } else {
382            // Simple format - check for direct fields
383            if let Some(body) = action.get("body").and_then(|v| v.as_str()) {
384                items.push(format!("RFC822 {{{}}}\r\n{}", body.len(), body));
385            }
386
387            if let Some(flags_arr) = action.get("flags").and_then(|v| v.as_array()) {
388                let flags: Vec<&str> = flags_arr.iter().filter_map(|v| v.as_str()).collect();
389                items.push(format!("FLAGS ({})", flags.join(" ")));
390            }
391
392            if let Some(uid) = action.get("uid").and_then(|v| v.as_u64()) {
393                items.push(format!("UID {}", uid));
394            }
395
396            if let Some(size) = action.get("size").and_then(|v| v.as_u64()) {
397                items.push(format!("RFC822.SIZE {}", size));
398            }
399        }
400
401        let response = format!("* {} FETCH ({})\r\n", sequence, items.join(" "));
402        Ok(ActionResult::Output(response.into_bytes()))
403    }
404
405    fn execute_send_imap_search(&self, action: serde_json::Value) -> Result<ActionResult> {
406        let empty_vec = vec![];
407        let results = action
408            .get("results")
409            .and_then(|v| v.as_array())
410            .unwrap_or(&empty_vec);
411
412        debug!("IMAP sending SEARCH response: {} results", results.len());
413
414        let ids: Vec<String> = results
415            .iter()
416            .filter_map(|v| v.as_u64().map(|n| n.to_string()))
417            .collect();
418
419        let response = if ids.is_empty() {
420            "* SEARCH\r\n".to_string()
421        } else {
422            format!("* SEARCH {}\r\n", ids.join(" "))
423        };
424
425        Ok(ActionResult::Output(response.into_bytes()))
426    }
427
428    fn execute_send_imap_exists(&self, action: serde_json::Value) -> Result<ActionResult> {
429        let count = action.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
430
431        debug!("IMAP sending EXISTS response: {} messages", count);
432
433        let response = format!("* {} EXISTS\r\n", count);
434        Ok(ActionResult::Output(response.into_bytes()))
435    }
436
437    fn execute_send_imap_recent(&self, action: serde_json::Value) -> Result<ActionResult> {
438        let count = action.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
439
440        debug!("IMAP sending RECENT response: {} messages", count);
441
442        let response = format!("* {} RECENT\r\n", count);
443        Ok(ActionResult::Output(response.into_bytes()))
444    }
445
446    fn execute_send_imap_flags(&self, action: serde_json::Value) -> Result<ActionResult> {
447        let flags = action
448            .get("flags")
449            .and_then(|v| v.as_array())
450            .map(|arr| {
451                arr.iter()
452                    .filter_map(|v| v.as_str())
453                    .collect::<Vec<&str>>()
454                    .join(" ")
455            })
456            .unwrap_or_default();
457
458        debug!("IMAP sending FLAGS response: {}", flags);
459
460        let response = format!("* FLAGS ({})\r\n", flags);
461        Ok(ActionResult::Output(response.into_bytes()))
462    }
463
464    fn execute_send_imap_expunge(&self, action: serde_json::Value) -> Result<ActionResult> {
465        let sequence = action
466            .get("sequence")
467            .and_then(|v| v.as_u64())
468            .context("Missing 'sequence' field in send_imap_expunge")?;
469
470        debug!("IMAP sending EXPUNGE response for message: {}", sequence);
471
472        let response = format!("* {} EXPUNGE\r\n", sequence);
473        Ok(ActionResult::Output(response.into_bytes()))
474    }
475
476    fn execute_send_imap_select(&self, action: serde_json::Value) -> Result<ActionResult> {
477        // Generate SELECT/EXAMINE response with mailbox status
478        let exists = action.get("exists").and_then(|v| v.as_u64()).unwrap_or(0);
479
480        let recent = action.get("recent").and_then(|v| v.as_u64());
481
482        let unseen = action.get("unseen").and_then(|v| v.as_u64());
483
484        let uidvalidity = action
485            .get("uidvalidity")
486            .and_then(|v| v.as_u64())
487            .unwrap_or(1);
488
489        let uidnext = action.get("uidnext").and_then(|v| v.as_u64());
490
491        let flags = action.get("flags").and_then(|v| v.as_array()).map(|arr| {
492            arr.iter()
493                .filter_map(|v| v.as_str())
494                .collect::<Vec<&str>>()
495                .join(" ")
496        });
497
498        let permanent_flags = action
499            .get("permanent_flags")
500            .and_then(|v| v.as_array())
501            .map(|arr| {
502                arr.iter()
503                    .filter_map(|v| v.as_str())
504                    .collect::<Vec<&str>>()
505                    .join(" ")
506            });
507
508        let _read_write = action
509            .get("read_write")
510            .and_then(|v| v.as_bool())
511            .unwrap_or(true);
512
513        debug!(
514            "IMAP sending SELECT response: exists={}, recent={:?}, unseen={:?}",
515            exists, recent, unseen
516        );
517
518        let mut response = String::new();
519
520        // Required responses
521        response.push_str(&format!("* {} EXISTS\r\n", exists));
522
523        if let Some(recent) = recent {
524            response.push_str(&format!("* {} RECENT\r\n", recent));
525        }
526
527        // Optional OK responses with codes
528        if let Some(unseen) = unseen {
529            response.push_str(&format!(
530                "* OK [UNSEEN {}] Message {} is first unseen\r\n",
531                unseen, unseen
532            ));
533        }
534
535        response.push_str(&format!(
536            "* OK [UIDVALIDITY {}] UIDs valid\r\n",
537            uidvalidity
538        ));
539
540        if let Some(uidnext) = uidnext {
541            response.push_str(&format!(
542                "* OK [UIDNEXT {}] Predicted next UID\r\n",
543                uidnext
544            ));
545        }
546
547        // FLAGS
548        if let Some(flags) = flags {
549            response.push_str(&format!("* FLAGS ({})\r\n", flags));
550        } else {
551            response.push_str("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n");
552        }
553
554        // PERMANENTFLAGS
555        if let Some(perm_flags) = permanent_flags {
556            response.push_str(&format!(
557                "* OK [PERMANENTFLAGS ({})] Limited\r\n",
558                perm_flags
559            ));
560        } else {
561            response.push_str("* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited\r\n");
562        }
563
564        // Note: We don't send the tagged response here - that should be sent separately
565        // via send_imap_response action
566
567        Ok(ActionResult::Output(response.into_bytes()))
568    }
569}
570
571// Implement Protocol trait (common functionality)
572impl Protocol for ImapProtocol {
573    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
574        // IMAP doesn't need async actions for now (all commands are synchronous request/response)
575        Vec::new()
576    }
577    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
578        vec![
579            send_imap_greeting_action(),
580            send_imap_response_action(),
581            send_imap_untagged_action(),
582            send_imap_list_action(),
583            send_imap_status_action(),
584            send_imap_fetch_action(),
585            send_imap_search_action(),
586            send_imap_select_action(),
587            send_imap_exists_action(),
588            send_imap_recent_action(),
589            send_imap_flags_action(),
590            send_imap_expunge_action(),
591            wait_for_more_action(),
592            close_connection_action(),
593        ]
594    }
595    fn protocol_name(&self) -> &'static str {
596        "IMAP"
597    }
598    fn get_event_types(&self) -> Vec<EventType> {
599        get_imap_event_types()
600    }
601    fn stack_name(&self) -> &'static str {
602        "ETH>IP>TCP>IMAP"
603    }
604    fn keywords(&self) -> Vec<&'static str> {
605        vec!["imap"]
606    }
607    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
608        use crate::protocol::metadata::{
609            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
610        };
611
612        ProtocolMetadataV2::builder()
613            // Beta: exercised against TWO real, independent clients — async-imap (Rust) and
614            // python3's stdlib imaplib — each completing login, SELECT, FETCH, SEARCH and
615            // LOGOUT. Not Stable: Stable additionally wants spec compliance and scripting
616            // support reviewed, which has not been done here.
617            .state(DevelopmentState::Beta)
618            .implementation(
619                "Manual line-based IMAP4rev1 parsing (tag/command/args split), plain TCP only",
620            )
621            .llm_control("Authentication + mailbox ops + FETCH")
622            .e2e_testing(
623                "TWO independent clients, neither of them a crate this server frames with (it \
624                 frames by hand). \
625                 (1) async-imap 0.11, a Rust IMAP client, in \
626                 tests/server/imap/e2e_client_test.rs -- eleven tests, none #[ignore]d and none \
627                 skip-gated (async-imap is a plain optional dependency the `imap` feature turns \
628                 on, so it compiles wherever the feature does). It completes LOGIN success and \
629                 failure, CAPABILITY, LIST, SELECT and EXAMINE with the parsed EXISTS count \
630                 asserted, FETCH with decoded messages, SEARCH, STATUS, NOOP/LOGOUT and \
631                 concurrent sessions -- all through the client's own parser, so a reply it \
632                 rejected would fail rather than be counted as bytes on a socket. \
633                 (2) python3's stdlib `imaplib`, in tests/server/imap/real_client_test.rs: a \
634                 single session of greeting, CAPABILITY, LOGIN, SELECT, SEARCH, FETCH and \
635                 LOGOUT, asserted on what imaplib parsed. It adds three things async-imap does \
636                 not reach here -- the literal BYTE count (the fetched body carries multi-byte \
637                 UTF-8, so a `{n}` counted in characters desynchronises the connection), \
638                 that imaplib reads NetGet's answer to its own CAPABILITY command (the \
639                 greeting there carries no capability code, so it is the only source), and \
640                 select()'s return value, which is the untagged EXISTS count rather \
641                 than the tagged completion. It FAILS rather than skips when python3 is absent, \
642                 and imaplib needs no install of its own. \
643                 Verified non-vacuous by breaking the server twice: `body.len()` made \
644                 `body.chars().count()` left the FETCH completing OK while imaplib returned no \
645                 FETCH data at all, and removing the `* n EXISTS` line left SELECT completing \
646                 OK while imaplib's select() returned [None]. In both cases the TAGGED response \
647                 was still correct, which is what a test asserting only on the completion would \
648                 have looked at. \
649                 This field read `Raw TCP client issuing tagged IMAP commands` for a long time, \
650                 which described a test that no longer exists and UNDER-stated the evidence by \
651                 the whole distance between a hand-written prober and a real client. \
652                 UNPROVEN: IMAPS, STARTTLS, SASL, and literal continuation ('+') handling, none \
653                 of which are implemented.",
654            )
655            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(143))
656            .well_known_port(143)
657            .max_inbound_bytes(crate::server::imap::MAX_COMMAND_BYTES)
658            .notes(
659                "Tracks session state (NotAuthenticated/Authenticated/Selected/Logout) but stores \
660                 no mailboxes or messages - the model answers every FETCH. No IMAPS, no STARTTLS, \
661                 no SASL, no literal continuation ('+') handling.",
662            )
663            .build()
664    }
665    fn description(&self) -> &'static str {
666        "IMAP mail server"
667    }
668    fn example_prompt(&self) -> &'static str {
669        "Start an IMAP mail server on port 143"
670    }
671    fn group_name(&self) -> &'static str {
672        "Application"
673    }
674    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
675        use crate::llm::actions::StartupExamples;
676        StartupExamples::new(
677            // LLM-driven example
678            json!({
679                "type": "open_server",
680                "port": 143,
681                "base_stack": "imap",
682                "instruction": "IMAP server with INBOX containing 5 messages, accept login for 'testuser'"
683            }),
684            // Script-based example
685            json!({
686                "type": "open_server",
687                "port": 143,
688                "base_stack": "imap",
689                "event_handlers": [{
690                    "event_pattern": "imap_command",
691                    "handler": {
692                        "type": "script",
693                        "language": "python",
694                        "code": "# Handle IMAP commands\ntag = event.get('tag', 'A001')\ncmd = event.get('command', '').upper()\nif cmd == 'LOGIN':\n    respond([{'type': 'send_imap_response', 'tag': tag, 'status': 'OK', 'message': 'LOGIN completed'}])\nelif cmd == 'SELECT':\n    respond([{'type': 'send_imap_select', 'exists': 5, 'recent': 0}, {'type': 'send_imap_response', 'tag': tag, 'status': 'OK', 'code': 'READ-WRITE', 'message': 'SELECT completed'}])\nelse:\n    respond([{'type': 'send_imap_response', 'tag': tag, 'status': 'OK', 'message': 'Completed'}])"
695                    }
696                }]
697            }),
698            // Static handler example
699            json!({
700                "type": "open_server",
701                "port": 143,
702                "base_stack": "imap",
703                "event_handlers": [{
704                    "event_pattern": "imap_command",
705                    "handler": {
706                        "type": "static",
707                        "actions": [{
708                            "type": "send_imap_response",
709                            "tag": "A001",
710                            "status": "OK",
711                            "message": "Completed"
712                        }]
713                    }
714                }]
715            }),
716        )
717    }
718}
719
720// Implement Server trait (server-specific functionality)
721impl Server for ImapProtocol {
722    fn spawn(
723        &self,
724        ctx: crate::protocol::SpawnContext,
725    ) -> std::pin::Pin<
726        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
727    > {
728        Box::pin(async move {
729            use crate::server::imap::ImapServer;
730            ImapServer::spawn_with_llm_actions(
731                ctx.legacy_listen_addr(),
732                ctx.llm_client,
733                ctx.state,
734                ctx.status_tx,
735                ctx.server_id,
736            )
737            .await
738        })
739    }
740    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
741        let action_type = action
742            .get("type")
743            .and_then(|v| v.as_str())
744            .context("Missing 'type' field in action")?
745            .to_string();
746
747        let result = match action_type.as_str() {
748            "send_imap_greeting" => self.execute_send_imap_greeting(action),
749            "send_imap_response" => self.execute_send_imap_response(action),
750            "send_imap_untagged" => self.execute_send_imap_untagged(action),
751            "send_imap_list" => self.execute_send_imap_list(action),
752            "send_imap_status" => self.execute_send_imap_status(action),
753            "send_imap_fetch" => self.execute_send_imap_fetch(action),
754            "send_imap_search" => self.execute_send_imap_search(action),
755            "send_imap_select" => self.execute_send_imap_select(action),
756            "send_imap_exists" => self.execute_send_imap_exists(action),
757            "send_imap_recent" => self.execute_send_imap_recent(action),
758            "send_imap_flags" => self.execute_send_imap_flags(action),
759            "send_imap_expunge" => self.execute_send_imap_expunge(action),
760            "wait_for_more" => Ok(ActionResult::WaitForMore),
761            "close_connection" => Ok(ActionResult::CloseConnection),
762            _ => Err(anyhow::anyhow!("Unknown IMAP action: {}", action_type)),
763        };
764
765        // One choke point, so no action can put a mis-framed literal on the wire.
766        //
767        // `send_imap_response` is the one that needed it — its `response` field relays a
768        // pre-framed, model-authored response verbatim — but the check belongs here rather
769        // than in that one arm: every other action reaches the same socket, and a guard that
770        // covers the path nobody has abused yet is the point of having one. The actions that
771        // build their own literals (`send_imap_fetch` counts `body.len()`) are
772        // correct-by-construction and pass unchanged.
773        match result {
774            Ok(ActionResult::Output(bytes)) => {
775                validate_imap_literals(&bytes).with_context(|| {
776                    format!("refusing to send the {action_type} response as framed")
777                })?;
778                Ok(ActionResult::Output(bytes))
779            }
780            other => other,
781        }
782    }
783}
784
785// ============================================================================
786// Action Definitions
787// ============================================================================
788
789fn send_imap_greeting_action() -> ActionDefinition {
790    ActionDefinition {
791        name: "send_imap_greeting".to_string(),
792        description: "The untagged '* OK [CAPABILITY ...]' greeting, sent once when a client \
793                      connects. Its capability list is also NetGet's answer to the CAPABILITY \
794                      command, so list only what your instruction says the server supports; \
795                      IMAP4rev1 is always included."
796            .to_string(),
797        parameters: vec![
798            Parameter {
799                name: "hostname".to_string(),
800                type_hint: "string".to_string(),
801                description: "Server hostname (default: localhost)".to_string(),
802                required: false,
803            },
804            Parameter {
805                name: "capabilities".to_string(),
806                type_hint: "array".to_string(),
807                description: "Capabilities beyond IMAP4rev1, one per entry (default: none)"
808                    .to_string(),
809                required: false,
810            },
811        ],
812        example: json!({
813            "type": "send_imap_greeting",
814            "hostname": "mail.example.com",
815            "capabilities": ["IMAP4rev1"]
816        }),
817        log_template: Some(
818            LogTemplate::new()
819                .with_info("-> IMAP greeting {hostname}")
820                .with_debug("IMAP send_imap_greeting: {hostname}"),
821        ),
822    }
823}
824
825fn send_imap_response_action() -> ActionDefinition {
826    ActionDefinition {
827        name: "send_imap_response".to_string(),
828        description: "Send an IMAP response line. Normally used for the tagged completion of a \
829                      command: give 'tag' and 'status' and NetGet assembles '<tag> <status> \
830                      [code] message'. Alternatively give 'response' alone to emit one \
831                      pre-formatted line verbatim (useful for untagged banners)."
832            .to_string(),
833        parameters: vec![
834            Parameter {
835                name: "tag".to_string(),
836                type_hint: "string".to_string(),
837                description: "Command tag echoed from the client's request (e.g. 'A001'). \
838                              Required unless 'response' is used."
839                    .to_string(),
840                required: false,
841            },
842            Parameter {
843                name: "status".to_string(),
844                type_hint: "string".to_string(),
845                description: "Response status: OK, NO, or BAD (default: OK)".to_string(),
846                required: false,
847            },
848            Parameter {
849                name: "message".to_string(),
850                type_hint: "string".to_string(),
851                description: "Human-readable text after the status".to_string(),
852                required: false,
853            },
854            Parameter {
855                name: "code".to_string(),
856                type_hint: "string".to_string(),
857                description: "Optional response code in brackets (e.g., READ-WRITE, READ-ONLY)"
858                    .to_string(),
859                required: false,
860            },
861            Parameter {
862                name: "response".to_string(),
863                type_hint: "string".to_string(),
864                description: "A complete response line to send as-is, replacing tag/status/\
865                              message/code (e.g. '* OK IMAP4rev1 Service Ready'). CRLF is added \
866                              if missing."
867                    .to_string(),
868                required: false,
869            },
870        ],
871        example: json!({
872            "type": "send_imap_response",
873            "tag": "A001",
874            "status": "OK",
875            "message": "completed"
876        }),
877        log_template: Some(
878            LogTemplate::new()
879                .with_info("-> IMAP {tag} {status}")
880                .with_debug("IMAP send_imap_response: {tag} {status} {message}"),
881        ),
882    }
883}
884
885fn send_imap_untagged_action() -> ActionDefinition {
886    ActionDefinition {
887        name: "send_imap_untagged".to_string(),
888        description: "Send untagged IMAP response (informational data)".to_string(),
889        parameters: vec![
890            Parameter {
891                name: "response_type".to_string(),
892                type_hint: "string".to_string(),
893                description: "Type of untagged response (e.g., OK, BYE, NO, BAD, CAPABILITY)"
894                    .to_string(),
895                required: true,
896            },
897            Parameter {
898                name: "data".to_string(),
899                type_hint: "string".to_string(),
900                description: "Response data".to_string(),
901                required: false,
902            },
903        ],
904        example: json!({
905            "type": "send_imap_untagged",
906            "response_type": "OK",
907            "data": "[PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited"
908        }),
909        log_template: Some(
910            LogTemplate::new()
911                .with_info("-> IMAP * {response_type}")
912                .with_debug("IMAP send_imap_untagged: {response_type}"),
913        ),
914    }
915}
916
917fn send_imap_list_action() -> ActionDefinition {
918    ActionDefinition {
919        name: "send_imap_list".to_string(),
920        description: "The answer to LIST: one '* LIST' line per mailbox the instruction \
921                      says exists, and no other. NetGet adds the tagged OK. The example's \
922                      mailbox names are placeholders; never send them."
923            .to_string(),
924        parameters: vec![Parameter {
925            name: "mailboxes".to_string(),
926            type_hint: "array".to_string(),
927            description: "Array of mailbox objects with name, delimiter, and flags".to_string(),
928            required: true,
929        }],
930        example: json!({
931            "type": "send_imap_list",
932            "mailboxes": [
933                {
934                    "name": "Example-Folder-A",
935                    "delimiter": "/",
936                    "flags": []
937                },
938                {
939                    "name": "Example-Folder-B",
940                    "delimiter": "/",
941                    "flags": []
942                }
943            ]
944        }),
945        log_template: Some(
946            LogTemplate::new()
947                .with_info("-> IMAP LIST {mailboxes_len} mailboxes")
948                .with_debug("IMAP send_imap_list: {mailboxes_len} mailboxes"),
949        ),
950    }
951}
952
953fn send_imap_status_action() -> ActionDefinition {
954    ActionDefinition {
955        name: "send_imap_status".to_string(),
956        description: "Send IMAP STATUS response with mailbox status information".to_string(),
957        parameters: vec![
958            Parameter {
959                name: "mailbox".to_string(),
960                type_hint: "string".to_string(),
961                description: "Mailbox name".to_string(),
962                required: true,
963            },
964            Parameter {
965                name: "items".to_string(),
966                type_hint: "object".to_string(),
967                description: "Status items (MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN)"
968                    .to_string(),
969                required: true,
970            },
971        ],
972        example: json!({
973            "type": "send_imap_status",
974            "mailbox": "INBOX",
975            "items": {
976                "MESSAGES": 5,
977                "RECENT": 2,
978                "UIDNEXT": 1006,
979                "UIDVALIDITY": 1234567890,
980                "UNSEEN": 3
981            }
982        }),
983        log_template: Some(
984            LogTemplate::new()
985                .with_info("-> IMAP STATUS {mailbox}")
986                .with_debug("IMAP send_imap_status: {mailbox}"),
987        ),
988    }
989}
990
991fn send_imap_fetch_action() -> ActionDefinition {
992    ActionDefinition {
993        name: "send_imap_fetch".to_string(),
994        description: "Send IMAP FETCH response with message data".to_string(),
995        parameters: vec![
996            Parameter {
997                name: "sequence".to_string(),
998                type_hint: "number".to_string(),
999                description: "Message sequence number".to_string(),
1000                required: true,
1001            },
1002            Parameter {
1003                name: "data".to_string(),
1004                type_hint: "object".to_string(),
1005                description: "Message data (FLAGS, UID, RFC822.SIZE, BODY[], ENVELOPE, etc.)"
1006                    .to_string(),
1007                required: true,
1008            },
1009        ],
1010        example: json!({
1011            "type": "send_imap_fetch",
1012            "sequence": 1,
1013            "data": {
1014                "FLAGS": ["\\Seen"],
1015                "UID": 1001,
1016                "RFC822.SIZE": 2048,
1017                "BODY[]": "From: sender@example.com\r\nSubject: Test\r\n\r\nHello World"
1018            }
1019        }),
1020        log_template: Some(
1021            LogTemplate::new()
1022                .with_info("-> IMAP FETCH {sequence}")
1023                .with_debug("IMAP send_imap_fetch: sequence={sequence}"),
1024        ),
1025    }
1026}
1027
1028fn send_imap_search_action() -> ActionDefinition {
1029    ActionDefinition {
1030        name: "send_imap_search".to_string(),
1031        description: "Send IMAP SEARCH response with matching message IDs".to_string(),
1032        parameters: vec![Parameter {
1033            name: "results".to_string(),
1034            type_hint: "array".to_string(),
1035            description: "Array of message sequence numbers matching search criteria".to_string(),
1036            required: true,
1037        }],
1038        example: json!({
1039            "type": "send_imap_search",
1040            "results": [1, 3, 5]
1041        }),
1042        log_template: Some(
1043            LogTemplate::new()
1044                .with_info("-> IMAP SEARCH {results_len} results")
1045                .with_debug("IMAP send_imap_search: {results_len} results"),
1046        ),
1047    }
1048}
1049
1050fn send_imap_exists_action() -> ActionDefinition {
1051    ActionDefinition {
1052        name: "send_imap_exists".to_string(),
1053        description: "Send IMAP EXISTS response indicating number of messages in mailbox"
1054            .to_string(),
1055        parameters: vec![Parameter {
1056            name: "count".to_string(),
1057            type_hint: "number".to_string(),
1058            description: "Number of messages that exist".to_string(),
1059            required: true,
1060        }],
1061        example: json!({
1062            "type": "send_imap_exists",
1063            "count": 5
1064        }),
1065        log_template: Some(
1066            LogTemplate::new()
1067                .with_info("-> IMAP {count} EXISTS")
1068                .with_debug("IMAP send_imap_exists: {count}"),
1069        ),
1070    }
1071}
1072
1073fn send_imap_recent_action() -> ActionDefinition {
1074    ActionDefinition {
1075        name: "send_imap_recent".to_string(),
1076        description: "Send IMAP RECENT response indicating number of recent messages".to_string(),
1077        parameters: vec![Parameter {
1078            name: "count".to_string(),
1079            type_hint: "number".to_string(),
1080            description: "Number of recent messages".to_string(),
1081            required: true,
1082        }],
1083        example: json!({
1084            "type": "send_imap_recent",
1085            "count": 2
1086        }),
1087        log_template: Some(
1088            LogTemplate::new()
1089                .with_info("-> IMAP {count} RECENT")
1090                .with_debug("IMAP send_imap_recent: {count}"),
1091        ),
1092    }
1093}
1094
1095fn send_imap_flags_action() -> ActionDefinition {
1096    ActionDefinition {
1097        name: "send_imap_flags".to_string(),
1098        description: "Send IMAP FLAGS response with available message flags".to_string(),
1099        parameters: vec![Parameter {
1100            name: "flags".to_string(),
1101            type_hint: "array".to_string(),
1102            description: "Array of available flags".to_string(),
1103            required: true,
1104        }],
1105        example: json!({
1106            "type": "send_imap_flags",
1107            "flags": ["\\Seen", "\\Answered", "\\Flagged", "\\Deleted", "\\Draft"]
1108        }),
1109        log_template: Some(
1110            LogTemplate::new()
1111                .with_info("-> IMAP FLAGS")
1112                .with_debug("IMAP send_imap_flags: {flags_len} flags"),
1113        ),
1114    }
1115}
1116
1117fn send_imap_expunge_action() -> ActionDefinition {
1118    ActionDefinition {
1119        name: "send_imap_expunge".to_string(),
1120        description: "Send IMAP EXPUNGE response indicating a message was permanently removed"
1121            .to_string(),
1122        parameters: vec![Parameter {
1123            name: "sequence".to_string(),
1124            type_hint: "number".to_string(),
1125            description: "Sequence number of the expunged message".to_string(),
1126            required: true,
1127        }],
1128        example: json!({
1129            "type": "send_imap_expunge",
1130            "sequence": 3
1131        }),
1132        log_template: Some(
1133            LogTemplate::new()
1134                .with_info("-> IMAP {sequence} EXPUNGE")
1135                .with_debug("IMAP send_imap_expunge: {sequence}"),
1136        ),
1137    }
1138}
1139
1140fn send_imap_select_action() -> ActionDefinition {
1141    ActionDefinition {
1142        name: "send_imap_select".to_string(),
1143        description: "The answer to SELECT/EXAMINE: the untagged block every client reads, \
1144                      starting with '* <exists> EXISTS' - the mailbox's message count - then \
1145                      RECENT, UIDVALIDITY, UIDNEXT, FLAGS and PERMANENTFLAGS. NetGet adds the \
1146                      tagged OK. A bare tagged OK is not an answer to SELECT: without EXISTS the \
1147                      client does not know how many messages there are."
1148            .to_string(),
1149        parameters: vec![
1150            Parameter {
1151                name: "exists".to_string(),
1152                type_hint: "number".to_string(),
1153                description: "Number of messages in mailbox".to_string(),
1154                required: true,
1155            },
1156            Parameter {
1157                name: "recent".to_string(),
1158                type_hint: "number".to_string(),
1159                description: "Number of recent messages".to_string(),
1160                required: false,
1161            },
1162            Parameter {
1163                name: "unseen".to_string(),
1164                type_hint: "number".to_string(),
1165                description: "Sequence number of first unseen message".to_string(),
1166                required: false,
1167            },
1168            Parameter {
1169                name: "uidvalidity".to_string(),
1170                type_hint: "number".to_string(),
1171                description: "UID validity value (default: 1)".to_string(),
1172                required: false,
1173            },
1174            Parameter {
1175                name: "uidnext".to_string(),
1176                type_hint: "number".to_string(),
1177                description: "Predicted next UID".to_string(),
1178                required: false,
1179            },
1180            Parameter {
1181                name: "flags".to_string(),
1182                type_hint: "array".to_string(),
1183                description: "Available message flags".to_string(),
1184                required: false,
1185            },
1186            Parameter {
1187                name: "permanent_flags".to_string(),
1188                type_hint: "array".to_string(),
1189                description: "Permanent message flags".to_string(),
1190                required: false,
1191            },
1192            Parameter {
1193                name: "read_write".to_string(),
1194                type_hint: "boolean".to_string(),
1195                description: "Whether mailbox is read-write (default: true)".to_string(),
1196                required: false,
1197            },
1198        ],
1199        example: json!({
1200            "type": "send_imap_select",
1201            "exists": 5,
1202            "recent": 2,
1203            "unseen": 3,
1204            "uidvalidity": 1,
1205            "uidnext": 6,
1206            "flags": ["\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"],
1207            "permanent_flags": ["\\Deleted", "\\Seen", "\\*"],
1208            "read_write": true
1209        }),
1210        log_template: Some(
1211            LogTemplate::new()
1212                .with_info("-> IMAP SELECT {exists} msgs")
1213                .with_debug("IMAP send_imap_select: exists={exists}, recent={recent}"),
1214        ),
1215    }
1216}
1217
1218fn wait_for_more_action() -> ActionDefinition {
1219    ActionDefinition {
1220        name: "wait_for_more".to_string(),
1221        description: "Wait for more data from client before processing".to_string(),
1222        parameters: vec![],
1223        example: json!({
1224            "type": "wait_for_more"
1225        }),
1226        log_template: Some(LogTemplate::new().with_debug("IMAP waiting for more data")),
1227    }
1228}
1229
1230fn close_connection_action() -> ActionDefinition {
1231    ActionDefinition {
1232        name: "close_connection".to_string(),
1233        description: "Close the IMAP connection".to_string(),
1234        parameters: vec![],
1235        example: json!({
1236            "type": "close_connection"
1237        }),
1238        log_template: Some(
1239            LogTemplate::new()
1240                .with_info("IMAP connection closed")
1241                .with_debug("IMAP close_connection"),
1242        ),
1243    }
1244}
1245
1246// ============================================================================
1247// Event Types
1248// ============================================================================
1249
1250pub static IMAP_CONNECTION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1251    EventType::new(
1252        "imap_connection",
1253        "Initial IMAP connection established - send greeting",
1254        json!({
1255            "type": "send_imap_greeting",
1256            "hostname": "mail.example.com",
1257            "capabilities": ["IMAP4rev1"]
1258        }),
1259    )
1260    .with_parameters(vec![])
1261    // An IMAP greeting is an *untagged* line, so all three of these produce a valid one:
1262    // `send_imap_greeting` builds `* OK [CAPABILITY ...] ... Service Ready`, while
1263    // `send_imap_untagged` and `send_imap_response`'s single-string `response` form let the
1264    // model write the banner verbatim. Only `send_imap_greeting` used to be advertised, so a
1265    // model - or a test mock - answering with the raw banner it was told to send had its
1266    // action rejected as unknown, retried, and the connection then died before the greeting
1267    // was ever written.
1268    .with_actions(vec![
1269        send_imap_greeting_action(),
1270        send_imap_untagged_action(),
1271        send_imap_response_action(),
1272    ])
1273    .with_log_template(
1274        LogTemplate::new()
1275            .with_info("IMAP connection from {client_ip}")
1276            .with_debug("IMAP connection from {client_ip}:{client_port}")
1277            .with_trace("IMAP connection: {json_pretty(.)}"),
1278    )
1279});
1280
1281pub static IMAP_AUTH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1282    EventType::new(
1283        "imap_auth",
1284        "IMAP LOGIN command received - authenticate user",
1285        json!({
1286            "type": "send_imap_response",
1287            "tag": "A001",
1288            "status": "OK",
1289            "message": "LOGIN completed"
1290        }),
1291    )
1292    .with_parameters(vec![
1293        Parameter {
1294            name: "tag".to_string(),
1295            type_hint: "string".to_string(),
1296            description: "Command tag".to_string(),
1297            required: true,
1298        },
1299        Parameter {
1300            name: "username".to_string(),
1301            type_hint: "string".to_string(),
1302            description: "Username for authentication".to_string(),
1303            required: true,
1304        },
1305        Parameter {
1306            name: "password".to_string(),
1307            type_hint: "string".to_string(),
1308            description: "Password for authentication".to_string(),
1309            required: true,
1310        },
1311        Parameter {
1312            name: "answer_with".to_string(),
1313            type_hint: "string".to_string(),
1314            description: "Which action answers this LOGIN".to_string(),
1315            required: false,
1316        },
1317    ])
1318    .with_actions(vec![send_imap_response_action(), close_connection_action()])
1319    .with_log_template(
1320        LogTemplate::new()
1321            .with_info("IMAP LOGIN {client_ip} user={username}")
1322            .with_debug("IMAP auth from {client_ip}:{client_port}, user={username}")
1323            .with_trace("IMAP auth: {json_pretty(.)}"),
1324    )
1325});
1326
1327pub static IMAP_COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1328    EventType::new(
1329        "imap_command",
1330        "IMAP command received from client (SELECT, LIST, FETCH, STORE, SEARCH, LOGOUT, ...). \
1331         LOGIN is not delivered here - it raises imap_auth instead - and CAPABILITY is answered \
1332         by NetGet from the greeting's list. Untagged responses come first; the one tagged \
1333         response carrying the client's tag completes the command, and NetGet adds it when you \
1334         send only untagged data. answer_with names the action for this command.",
1335        json!([
1336            {"type": "send_imap_exists", "count": 5},
1337            {"type": "send_imap_response", "tag": "A002", "status": "OK", "code": "READ-WRITE", "message": "SELECT completed"}
1338        ]),
1339    )
1340    .with_parameters(vec![
1341        Parameter {
1342            name: "tag".to_string(),
1343            type_hint: "string".to_string(),
1344            description: "Command tag".to_string(),
1345            required: true,
1346        },
1347        Parameter {
1348            name: "command".to_string(),
1349            type_hint: "string".to_string(),
1350            description: "IMAP command (SELECT, LIST, FETCH, etc.)".to_string(),
1351            required: true,
1352        },
1353        Parameter {
1354            name: "answer_with".to_string(),
1355            type_hint: "string".to_string(),
1356            description: "Which action answers this command, when its name says".to_string(),
1357            required: false,
1358        },
1359        Parameter {
1360            name: "args".to_string(),
1361            type_hint: "string".to_string(),
1362            description: "Command arguments".to_string(),
1363            required: false,
1364        },
1365        Parameter {
1366            name: "session_state".to_string(),
1367            type_hint: "string".to_string(),
1368            description:
1369                "Current session state (NotAuthenticated, Authenticated, Selected, Logout)"
1370                    .to_string(),
1371            required: true,
1372        },
1373        Parameter {
1374            name: "authenticated_user".to_string(),
1375            type_hint: "string".to_string(),
1376            description: "Authenticated username (if any)".to_string(),
1377            required: false,
1378        },
1379        Parameter {
1380            name: "selected_mailbox".to_string(),
1381            type_hint: "string".to_string(),
1382            description: "Currently selected mailbox (if any)".to_string(),
1383            required: false,
1384        },
1385    ])
1386    // Must list every action that can legitimately answer a command: `call_llm` builds the
1387    // model's tool list from here, not from `get_sync_actions()`. `send_imap_select` was
1388    // missing, so the one action that emits a complete SELECT/EXAMINE response was unreachable
1389    // over the LLM path even though the protocol's own script example uses it.
1390    .with_actions(vec![
1391        send_imap_response_action(),
1392        send_imap_untagged_action(),
1393        send_imap_list_action(),
1394        send_imap_status_action(),
1395        send_imap_select_action(),
1396        send_imap_fetch_action(),
1397        send_imap_search_action(),
1398        send_imap_exists_action(),
1399        send_imap_recent_action(),
1400        send_imap_flags_action(),
1401        send_imap_expunge_action(),
1402        wait_for_more_action(),
1403        close_connection_action(),
1404    ])
1405    .with_log_template(
1406        LogTemplate::new()
1407            .with_info("IMAP {client_ip} {tag} {command}")
1408            .with_debug("IMAP command from {client_ip}:{client_port}: {tag} {command} {args}")
1409            .with_trace("IMAP command: {json_pretty(.)}"),
1410    )
1411});
1412
1413/// `imap_command` as raised for a command with one data answer: the same event id, offering that
1414/// answer's action and `send_imap_response` (for a NO or BAD), with a description naming the
1415/// command instead of listing every command there is.
1416///
1417/// Offered the whole vocabulary, llama3.1:8b answered LIST with a bare tagged OK - or a tagged
1418/// OK reading "Login successful. Folders: INBOX, Archive, Receipts." - in 6 of 8 replays of one
1419/// captured prompt; its reasoning said "User wants to log in", primed by the general
1420/// description's sentence about LOGIN. Offered `send_imap_list` and `send_imap_response` under a
1421/// description that names LIST, it answered with the list in 8 of 8. The id is unchanged, so
1422/// handlers for `imap_command` match as before, and `get_imap_event_types` still declares the one
1423/// event with every action.
1424static COMMAND_EVENTS: LazyLock<Vec<(&'static [&'static str], EventType)>> = LazyLock::new(|| {
1425    let narrowed = |description: &str, answer: ActionDefinition| {
1426        let mut event = IMAP_COMMAND_EVENT.clone();
1427        event.description = description.to_string();
1428        event.response_example = answer.example.clone();
1429        event.alternative_examples.clear();
1430        event.actions = vec![answer, send_imap_response_action()];
1431        event
1432    };
1433    vec![
1434        (
1435            &["LIST", "LSUB"][..],
1436            narrowed(
1437                "The client sent LIST (or LSUB), asking which mailboxes the account has. Answer \
1438                 with send_imap_list, one entry per mailbox; NetGet adds the tagged OK. \
1439                 send_imap_response with status NO or BAD refuses the command.",
1440                send_imap_list_action(),
1441            ),
1442        ),
1443        (
1444            &["SELECT", "EXAMINE"][..],
1445            narrowed(
1446                "The client sent SELECT (or EXAMINE), opening the mailbox named in args. Answer \
1447                 with send_imap_select, whose exists is the number of messages in that mailbox; \
1448                 NetGet adds the tagged OK. send_imap_response with status NO refuses it when \
1449                 there is no such mailbox.",
1450                send_imap_select_action(),
1451            ),
1452        ),
1453    ]
1454});
1455
1456/// The event to raise for `command`: its narrowed `imap_command` when it has one, the declared
1457/// event with every action otherwise.
1458pub fn command_event(command: &str) -> &'static EventType {
1459    COMMAND_EVENTS
1460        .iter()
1461        .find(|(commands, _)| commands.iter().any(|c| c.eq_ignore_ascii_case(command)))
1462        .map(|(_, event)| event)
1463        .unwrap_or(&IMAP_COMMAND_EVENT)
1464}
1465
1466pub fn get_imap_event_types() -> Vec<EventType> {
1467    vec![
1468        IMAP_CONNECTION_EVENT.clone(),
1469        IMAP_AUTH_EVENT.clone(),
1470        IMAP_COMMAND_EVENT.clone(),
1471    ]
1472}