1use 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
24const LITERAL_FOLLOWERS: &[u8] = b") \r";
33
34pub 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 if j == i + 1 || j >= response.len() || response[j] != b'}' {
79 i += 1;
80 continue;
81 }
82 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 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 i = start + declared;
124 }
125 Ok(())
126}
127
128pub 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 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 if let Some(response_str) = action.get("response").and_then(|v| v.as_str()) {
175 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 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 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 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 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 if let Some(data) = action.get("data").and_then(|v| v.as_object()) {
328 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 if let Some(val_str) = value.as_str() {
376 items.push(format!("{} {}", key.to_uppercase(), val_str));
377 }
378 }
379 }
380 }
381 } else {
382 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 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 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 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 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 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 Ok(ActionResult::Output(response.into_bytes()))
568 }
569}
570
571impl Protocol for ImapProtocol {
573 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
574 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 .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 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 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 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
720impl 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 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
785fn 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
1246pub 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 .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 .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
1413static 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
1456pub 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}