Skip to main content

netget/server/pop3/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::EventType;
7use crate::state::app_state::AppState;
8use anyhow::{Context, Result};
9use serde_json::json;
10use std::sync::LazyLock;
11use tracing::debug;
12
13/// Event: POP3 command received from client
14///
15/// This is the only event the POP3 server emits, so its action list must carry the protocol's
16/// full response vocabulary: `call_llm` builds the model's tool list from
17/// `event.event_type.actions`, never from [`Protocol::get_sync_actions`]. The list is taken
18/// straight from `get_sync_actions()` so the two can never drift apart.
19pub static POP3_COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
20    EventType::new(
21        "pop3_command",
22        "POP3 command received from client (USER, PASS, STAT, LIST, RETR, DELE, QUIT, etc.). \
23         The literal command 'CONNECTION_ESTABLISHED' is sent once when a client connects and \
24         must be answered with send_pop3_greeting. CAPA is answered by NetGet and never reaches \
25         you. Each command gets exactly one reply, and answer_with names the action for this \
26         one.",
27        json!({"type": "send_pop3_ok", "message": "command processed"}),
28    )
29    .with_parameters(vec![
30        Parameter {
31            name: "command".to_string(),
32            type_hint: "string".to_string(),
33            description:
34                "The POP3 command line (e.g., 'USER alice', 'STAT', 'RETR 1'), or the synthetic \
35                 'CONNECTION_ESTABLISHED' on a new connection"
36                    .to_string(),
37            required: true,
38        },
39        Parameter {
40            name: "connection_id".to_string(),
41            type_hint: "string".to_string(),
42            description: "Unique connection identifier".to_string(),
43            required: true,
44        },
45        Parameter {
46            name: "answer_with".to_string(),
47            type_hint: "string".to_string(),
48            description: "Which action answers this command".to_string(),
49            required: false,
50        },
51        Parameter {
52            name: "message_number".to_string(),
53            type_hint: "number".to_string(),
54            description: "RETR, TOP, LIST, UIDL, DELE: the message the command names".to_string(),
55            required: false,
56        },
57        Parameter {
58            name: "lines".to_string(),
59            type_hint: "number".to_string(),
60            description: "TOP only: how many body lines to send".to_string(),
61            required: false,
62        },
63    ])
64    .with_actions(Pop3Protocol::new().get_sync_actions())
65    .with_log_template(
66        LogTemplate::new()
67            .with_info("POP3 command: {command}")
68            .with_debug("POP3 {command} on {connection_id}")
69            .with_trace("POP3: {json_pretty(.)}"),
70    )
71});
72
73/// The message a `send_pop3_retr` carries, as text: `content` verbatim when the model wrote
74/// the whole message, otherwise built from the structured fields.
75///
76/// The structured form exists because a small model asked for "the message whose subject is
77/// X" writes `"subject": "X"` far more reliably than it writes RFC 5322 header syntax inside
78/// one string. NetGet writes the header lines (`From`, `To`, `Subject`, `Date`, in that order,
79/// each only when given), the blank line, and the body.
80fn pop3_message_text(action: &serde_json::Value) -> Result<String> {
81    if let Some(content) = action.get("content").and_then(|v| v.as_str()) {
82        return Ok(content.to_string());
83    }
84    let field = |name: &str| action.get(name).and_then(|v| v.as_str());
85    let mut text = String::new();
86    for (header, name) in [
87        ("From", "from"),
88        ("To", "to"),
89        ("Subject", "subject"),
90        ("Date", "date"),
91    ] {
92        if let Some(value) = field(name) {
93            // A header value is one line; a newline in it would start a forged header.
94            let value = value.replace(['\r', '\n'], " ");
95            text.push_str(&format!("{header}: {value}\n"));
96        }
97    }
98    let body = field("body");
99    if text.is_empty() && body.is_none() {
100        anyhow::bail!(
101            "send_pop3_retr needs the message: either 'content' (headers, a blank line, the \
102             body) or the fields from, to, subject and body"
103        );
104    }
105    text.push('\n');
106    text.push_str(body.unwrap_or(""));
107    Ok(text)
108}
109
110/// Prepare LLM-supplied message text for a POP3 multi-line response body.
111///
112/// Two things the model cannot be expected to get right, and which break real clients when
113/// they are wrong (RFC 1939 §3, "Responses to certain commands are multi-line"):
114///
115/// 1. **Byte-stuffing.** A body line beginning with `.` would otherwise be read as the
116///    response terminator, truncating the message. Such lines get a second leading `.`,
117///    which the client strips.
118/// 2. **Line endings.** Bare `\n` is normalised to `\r\n`, and the body is terminated with
119///    `\r\n` so the following `.\r\n` starts on its own line.
120///
121/// The result always ends with `\r\n` (or is empty), so callers can append `.\r\n` directly.
122pub fn format_pop3_multiline_body(content: &str) -> String {
123    if content.is_empty() {
124        return String::new();
125    }
126
127    let mut out = String::with_capacity(content.len() + 16);
128    for line in content.replace("\r\n", "\n").split('\n') {
129        if line.starts_with('.') {
130            out.push('.');
131        }
132        out.push_str(line);
133        out.push_str("\r\n");
134    }
135
136    // `split` yields a trailing empty field when the content already ended in a newline;
137    // that produced one spurious blank line, so drop it.
138    if content.ends_with('\n') {
139        out.truncate(out.len() - 2);
140    }
141
142    out
143}
144
145pub struct Pop3Protocol;
146
147impl Pop3Protocol {
148    pub fn new() -> Self {
149        Self
150    }
151
152    fn execute_send_pop3_greeting(&self, action: serde_json::Value) -> Result<ActionResult> {
153        let message = action
154            .get("message")
155            .and_then(|v| v.as_str())
156            .unwrap_or("POP3 server ready");
157
158        let response = format!("+OK {}\r\n", message);
159
160        debug!("POP3 sending greeting: {}", response.trim());
161        Ok(ActionResult::Output(response.as_bytes().to_vec()))
162    }
163
164    fn execute_send_pop3_ok(&self, action: serde_json::Value) -> Result<ActionResult> {
165        let message = action.get("message").and_then(|v| v.as_str()).unwrap_or("");
166
167        let response = if message.is_empty() {
168            "+OK\r\n".to_string()
169        } else {
170            format!("+OK {}\r\n", message)
171        };
172
173        debug!("POP3 sending +OK: {}", message);
174        Ok(ActionResult::Output(response.as_bytes().to_vec()))
175    }
176
177    fn execute_send_pop3_err(&self, action: serde_json::Value) -> Result<ActionResult> {
178        let message = action
179            .get("message")
180            .and_then(|v| v.as_str())
181            .context("Missing 'message' parameter")?;
182
183        let response = format!("-ERR {}\r\n", message);
184
185        debug!("POP3 sending -ERR: {}", message);
186        Ok(ActionResult::Output(response.as_bytes().to_vec()))
187    }
188
189    fn execute_send_pop3_stat(&self, action: serde_json::Value) -> Result<ActionResult> {
190        let message_count = action
191            .get("message_count")
192            .and_then(|v| v.as_u64())
193            .context("Missing 'message_count' parameter")?;
194
195        let total_size = action
196            .get("total_size")
197            .and_then(|v| v.as_u64())
198            .context("Missing 'total_size' parameter")?;
199
200        let response = format!("+OK {} {}\r\n", message_count, total_size);
201
202        debug!(
203            "POP3 sending STAT: {} messages, {} bytes",
204            message_count, total_size
205        );
206        Ok(ActionResult::Output(response.as_bytes().to_vec()))
207    }
208
209    fn execute_send_pop3_list(&self, action: serde_json::Value) -> Result<ActionResult> {
210        let messages = action
211            .get("messages")
212            .and_then(|v| v.as_array())
213            .context("Missing 'messages' parameter")?;
214
215        let mut response = format!("+OK {} messages\r\n", messages.len());
216        for msg in messages {
217            let id = msg.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
218            let size = msg.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
219            response.push_str(&format!("{} {}\r\n", id, size));
220        }
221        response.push_str(".\r\n");
222
223        debug!("POP3 sending LIST with {} messages", messages.len());
224        Ok(ActionResult::Output(response.as_bytes().to_vec()))
225    }
226
227    fn execute_send_pop3_uidl(&self, action: serde_json::Value) -> Result<ActionResult> {
228        let messages = action
229            .get("messages")
230            .and_then(|v| v.as_array())
231            .context("Missing 'messages' parameter")?;
232
233        let mut response = format!("+OK {} messages\r\n", messages.len());
234        for msg in messages {
235            let id = msg.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
236            let uidl = msg.get("uidl").and_then(|v| v.as_str()).unwrap_or("");
237            response.push_str(&format!("{} {}\r\n", id, uidl));
238        }
239        response.push_str(".\r\n");
240
241        debug!("POP3 sending UIDL with {} messages", messages.len());
242        Ok(ActionResult::Output(response.as_bytes().to_vec()))
243    }
244
245    fn execute_send_pop3_retr(&self, action: serde_json::Value) -> Result<ActionResult> {
246        let content = pop3_message_text(&action)?;
247
248        let body = format_pop3_multiline_body(&content);
249
250        // RFC 1939 treats the octet count as informational (the terminating "." is what ends
251        // the response), so an LLM-supplied `size` is honoured, but omitting it yields the
252        // byte count actually written - which is what a client comparing the two expects.
253        let size = action
254            .get("size")
255            .and_then(|v| v.as_u64())
256            .unwrap_or_else(|| body.len() as u64);
257
258        let response = format!("+OK {} octets\r\n{}.\r\n", size, body);
259
260        debug!("POP3 sending RETR with {} octets", size);
261        Ok(ActionResult::Output(response.into_bytes()))
262    }
263
264    fn execute_send_pop3_top(&self, action: serde_json::Value) -> Result<ActionResult> {
265        let content = action
266            .get("content")
267            .and_then(|v| v.as_str())
268            .context("Missing 'content' parameter")?;
269
270        let response = format!("+OK\r\n{}.\r\n", format_pop3_multiline_body(content));
271
272        debug!("POP3 sending TOP");
273        Ok(ActionResult::Output(response.into_bytes()))
274    }
275
276    fn execute_send_pop3_message(&self, action: serde_json::Value) -> Result<ActionResult> {
277        let message = action
278            .get("message")
279            .and_then(|v| v.as_str())
280            .context("Missing 'message' parameter")?;
281
282        // Ensure message ends with \r\n
283        let formatted = if message.ends_with("\r\n") {
284            message.to_string()
285        } else if message.ends_with('\n') {
286            format!("{}\r", message.trim_end_matches('\n'))
287        } else {
288            format!("{}\r\n", message)
289        };
290
291        debug!("POP3 sending custom message: {}", formatted.trim());
292        Ok(ActionResult::Output(formatted.as_bytes().to_vec()))
293    }
294}
295
296// Implement Protocol trait (common functionality)
297impl Protocol for Pop3Protocol {
298    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
299        // No TLS parameters. Six (`enable_tls`, `tls_common_name`, `tls_san_dns_names`,
300        // `tls_validity_days`, `tls_organization`, `tls_organizational_unit`) used to be
301        // declared here while `spawn` hardcoded `tls_config = None`, so a caller asking for
302        // POP3S got a plain-text listener and no warning. Declaring nothing makes
303        // `enable_tls: true` fail loudly instead. See the note in `spawn`.
304        //
305        // The two below are the read deadlines. Their right value is a property of who is on
306        // the other end, which only the operator knows.
307        vec![
308            ParameterDefinition {
309                name: "first_byte_timeout_secs".to_string(),
310                type_hint: "number".to_string(),
311                description: "Seconds a connected peer may send no command at all - not even \
312                              a CAPA or USER after the +OK greeting - before the server closes \
313                              it. Default 300, matching the window a `manual` rule gives a \
314                              human to answer one event. The peer is often NetGet's own POP3 \
315                              client, which reads the greeting in its read loop and writes \
316                              nothing until an action or [ send message ] says to. Lower it to \
317                              60, Dovecot's `login_timeout`, for a listener exposed to \
318                              strangers."
319                    .to_string(),
320                required: false,
321                example: json!(300),
322                default: Some(serde_json::json!(
323                    super::FIRST_COMMAND_READ_TIMEOUT.as_secs()
324                )),
325            },
326            ParameterDefinition {
327                name: "idle_timeout_secs".to_string(),
328                type_hint: "number".to_string(),
329                description: "Seconds an established session may go without a further command \
330                              before the server closes it. Default 600. RFC 1939 section 3 \
331                              says this timer MUST be at least 10 minutes, so a value below \
332                              600 puts the server outside the specification - it is accepted, \
333                              but that is the trade you are making."
334                    .to_string(),
335                required: false,
336                example: json!(600),
337                default: Some(serde_json::json!(
338                    super::IDLE_BETWEEN_COMMANDS_TIMEOUT.as_secs()
339                )),
340            },
341        ]
342    }
343
344    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
345        // No user-triggered actions. A `close_pop3_connection` action used to be advertised
346        // here, but `execute_action` had no arm for it, so every attempt to use it failed with
347        // "Unknown POP3 action". Connections are closed from the command loop via the sync
348        // `close_connection` action instead.
349        Vec::new()
350    }
351
352    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
353        vec![
354            ActionDefinition {
355                name: "send_pop3_ok".to_string(),
356                description: "A single +OK line: accepts USER, PASS, DELE, NOOP, RSET, QUIT, or \
357                              answers LIST/UIDL for one message. Never the answer to RETR, TOP, \
358                              STAT or a whole-mailbox LIST - those have their own actions, and \
359                              a bare +OK leaves the client waiting for data that never comes."
360                    .to_string(),
361                parameters: vec![Parameter {
362                    name: "message".to_string(),
363                    type_hint: "string".to_string(),
364                    description: "Optional message after +OK".to_string(),
365                    required: false,
366                }],
367                example: json!({
368                    "type": "send_pop3_ok",
369                    "message": "user accepted"
370                }),
371                log_template: Some(
372                    LogTemplate::new()
373                        .with_info("-> POP3 +OK {message}")
374                        .with_debug("POP3 send_pop3_ok: message={message}"),
375                ),
376            },
377            ActionDefinition {
378                name: "send_pop3_err".to_string(),
379                description: "Send POP3 -ERR response".to_string(),
380                parameters: vec![Parameter {
381                    name: "message".to_string(),
382                    type_hint: "string".to_string(),
383                    description: "Error message".to_string(),
384                    required: true,
385                }],
386                example: json!({
387                    "type": "send_pop3_err",
388                    "message": "Invalid credentials"
389                }),
390                log_template: Some(
391                    LogTemplate::new()
392                        .with_info("-> POP3 -ERR {message}")
393                        .with_debug("POP3 send_pop3_err: message={message}"),
394                ),
395            },
396            ActionDefinition {
397                name: "send_pop3_greeting".to_string(),
398                description: "Send POP3 greeting banner (sent automatically on connect)"
399                    .to_string(),
400                parameters: vec![Parameter {
401                    name: "message".to_string(),
402                    type_hint: "string".to_string(),
403                    description: "Greeting message (e.g., server name)".to_string(),
404                    required: false,
405                }],
406                example: json!({
407                    "type": "send_pop3_greeting",
408                    "message": "POP3 server ready"
409                }),
410                log_template: Some(
411                    LogTemplate::new()
412                        .with_info("-> POP3 greeting: {message}")
413                        .with_debug("POP3 send_pop3_greeting: message={message}"),
414                ),
415            },
416            ActionDefinition {
417                name: "send_pop3_stat".to_string(),
418                description: "Send POP3 STAT response with message count and total size"
419                    .to_string(),
420                parameters: vec![
421                    Parameter {
422                        name: "message_count".to_string(),
423                        type_hint: "number".to_string(),
424                        description: "Number of messages in mailbox".to_string(),
425                        required: true,
426                    },
427                    Parameter {
428                        name: "total_size".to_string(),
429                        type_hint: "number".to_string(),
430                        description: "Total size of all messages in octets".to_string(),
431                        required: true,
432                    },
433                ],
434                example: json!({
435                    "type": "send_pop3_stat",
436                    "message_count": 3,
437                    "total_size": 1024
438                }),
439                log_template: Some(
440                    LogTemplate::new()
441                        .with_info("-> POP3 STAT {message_count} {total_size}")
442                        .with_debug(
443                            "POP3 send_pop3_stat: {message_count} messages, {total_size} bytes",
444                        ),
445                ),
446            },
447            ActionDefinition {
448                name: "send_pop3_list".to_string(),
449                description: "Send POP3 LIST response with message sizes".to_string(),
450                parameters: vec![Parameter {
451                    name: "messages".to_string(),
452                    type_hint: "array".to_string(),
453                    description: "Array of message objects with 'id' and 'size' fields".to_string(),
454                    required: true,
455                }],
456                example: json!({
457                    "type": "send_pop3_list",
458                    "messages": [{"id": 1, "size": 512}, {"id": 2, "size": 256}]
459                }),
460                log_template: Some(
461                    LogTemplate::new()
462                        .with_info("-> POP3 LIST {messages_len} messages")
463                        .with_debug("POP3 send_pop3_list: {messages_len} messages"),
464                ),
465            },
466            ActionDefinition {
467                name: "send_pop3_uidl".to_string(),
468                description: "Send POP3 UIDL response with unique message identifiers".to_string(),
469                parameters: vec![Parameter {
470                    name: "messages".to_string(),
471                    type_hint: "array".to_string(),
472                    description: "Array of message objects with 'id' and 'uidl' fields".to_string(),
473                    required: true,
474                }],
475                example: json!({
476                    "type": "send_pop3_uidl",
477                    "messages": [{"id": 1, "uidl": "msg-abc123"}, {"id": 2, "uidl": "msg-def456"}]
478                }),
479                log_template: Some(
480                    LogTemplate::new()
481                        .with_info("-> POP3 UIDL {messages_len} messages")
482                        .with_debug("POP3 send_pop3_uidl: {messages_len} messages"),
483                ),
484            },
485            ActionDefinition {
486                name: "send_pop3_retr".to_string(),
487                description: "The answer to RETR: the whole message. Give it as from, to, \
488                              subject and body and NetGet writes the header lines, or as \
489                              content (headers, a blank line, the body). NetGet adds the octet \
490                              count and the terminating '.' line, converts newlines to CRLF and \
491                              byte-stuffs any line starting with '.'. The example's values are \
492                              placeholders; send the message your instruction describes."
493                    .to_string(),
494                parameters: vec![
495                    Parameter {
496                        name: "from".to_string(),
497                        type_hint: "string".to_string(),
498                        description: "Sender address (the From header)".to_string(),
499                        required: false,
500                    },
501                    Parameter {
502                        name: "to".to_string(),
503                        type_hint: "string".to_string(),
504                        description: "Recipient address (the To header)".to_string(),
505                        required: false,
506                    },
507                    Parameter {
508                        name: "subject".to_string(),
509                        type_hint: "string".to_string(),
510                        description: "The Subject header, verbatim".to_string(),
511                        required: false,
512                    },
513                    Parameter {
514                        name: "date".to_string(),
515                        type_hint: "string".to_string(),
516                        description: "The Date header (optional)".to_string(),
517                        required: false,
518                    },
519                    Parameter {
520                        name: "body".to_string(),
521                        type_hint: "string".to_string(),
522                        description: "Message body text".to_string(),
523                        required: false,
524                    },
525                    Parameter {
526                        name: "content".to_string(),
527                        type_hint: "string".to_string(),
528                        description: "Instead of the fields above: the whole message as RFC \
529                                      5322 text - headers, a blank line, then the body"
530                            .to_string(),
531                        required: false,
532                    },
533                    Parameter {
534                        name: "size".to_string(),
535                        type_hint: "number".to_string(),
536                        description: "Octet count to advertise. Omit it and NetGet reports the \
537                                      real byte length of what it sends."
538                            .to_string(),
539                        required: false,
540                    },
541                ],
542                example: json!({
543                    "type": "send_pop3_retr",
544                    "from": "sender@example.invalid",
545                    "to": "mailbox@example.invalid",
546                    "subject": "Example subject",
547                    "body": "Example body."
548                }),
549                log_template: Some(
550                    LogTemplate::new()
551                        .with_info("-> POP3 RETR {size}B")
552                        .with_debug("POP3 send_pop3_retr: size={size} bytes"),
553                ),
554            },
555            ActionDefinition {
556                name: "send_pop3_top".to_string(),
557                description: "Send POP3 TOP response: full headers plus the first N body lines \
558                              the client asked for. NetGet adds the terminating '.' line and \
559                              handles CRLF and byte-stuffing."
560                    .to_string(),
561                parameters: vec![Parameter {
562                    name: "content".to_string(),
563                    type_hint: "string".to_string(),
564                    description: "Email headers, a blank line, then the requested body lines"
565                        .to_string(),
566                    required: true,
567                }],
568                example: json!({
569                    "type": "send_pop3_top",
570                    "content": "From: sender@example.com\nTo: recipient@example.com\nSubject: Test\n\nFirst line"
571                }),
572                log_template: Some(
573                    LogTemplate::new()
574                        .with_info("-> POP3 TOP")
575                        .with_debug("POP3 send_pop3_top"),
576                ),
577            },
578            ActionDefinition {
579                name: "send_pop3_message".to_string(),
580                description: "Send custom POP3 response".to_string(),
581                parameters: vec![Parameter {
582                    name: "message".to_string(),
583                    type_hint: "string".to_string(),
584                    description: "Full POP3 response line (including +OK or -ERR)".to_string(),
585                    required: true,
586                }],
587                example: json!({
588                    "type": "send_pop3_message",
589                    "message": "+OK Custom response"
590                }),
591                log_template: Some(
592                    LogTemplate::new()
593                        .with_info("-> POP3: {preview(message,60)}")
594                        .with_debug("POP3 send_pop3_message: {message}"),
595                ),
596            },
597            ActionDefinition {
598                name: "wait_for_more".to_string(),
599                description: "Do not send any response, wait for more commands from client"
600                    .to_string(),
601                parameters: vec![],
602                example: json!({
603                    "type": "wait_for_more"
604                }),
605                log_template: Some(
606                    LogTemplate::new()
607                        .with_info("POP3 waiting for more data")
608                        .with_debug("POP3 wait_for_more"),
609                ),
610            },
611            ActionDefinition {
612                name: "close_connection".to_string(),
613                description: "Close the POP3 connection".to_string(),
614                parameters: vec![],
615                example: json!({
616                    "type": "close_connection"
617                }),
618                log_template: Some(
619                    LogTemplate::new()
620                        .with_info("POP3 connection closed")
621                        .with_debug("POP3 close_connection"),
622                ),
623            },
624        ]
625    }
626
627    fn protocol_name(&self) -> &'static str {
628        "POP3"
629    }
630
631    fn get_event_types(&self) -> Vec<EventType> {
632        // Must be the same EventType the server actually emits. Building a second, ad-hoc one
633        // here previously meant the documentation served to the model advertised no parameters,
634        // no actions and a "placeholder" response example.
635        vec![POP3_COMMAND_EVENT.clone()]
636    }
637
638    fn stack_name(&self) -> &'static str {
639        "ETH>IP>TCP>POP3"
640    }
641
642    fn keywords(&self) -> Vec<&'static str> {
643        vec!["pop3", "pop3 server", "via pop3", "post office protocol"]
644    }
645
646    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
647        use crate::protocol::metadata::{
648            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
649        };
650
651        ProtocolMetadataV2::builder()
652            .state(DevelopmentState::Experimental)
653            .implementation(
654                "Manual line-based TCP implementation with full LLM control over protocol responses",
655            )
656            .llm_control("Full control over POP3 responses (+OK, -ERR, STAT, LIST, RETR, etc.)")
657            .e2e_testing("Manual TCP client with line-based protocol testing")
658            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(110))
659            .well_known_port(110)
660            .max_inbound_bytes(crate::server::pop3::MAX_COMMAND_BYTES)
661            .notes(
662                "No mailbox storage: the model answers STAT/LIST/RETR itself. Plain TCP only - \
663                 neither POP3S nor STLS is available.",
664            )
665            .build()
666    }
667
668    fn description(&self) -> &'static str {
669        "POP3 email retrieval server (RFC 1939)"
670    }
671
672    fn example_prompt(&self) -> &'static str {
673        "Listen on port 110 via POP3. Accept all authentication and return 3 test messages."
674    }
675
676    fn group_name(&self) -> &'static str {
677        "Application"
678    }
679    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
680        use crate::llm::actions::StartupExamples;
681        StartupExamples::new(
682            // LLM-driven example
683            json!({
684                "type": "open_server",
685                "port": 110,
686                "base_stack": "pop3",
687                "instruction": "POP3 server with 3 test messages, accept any credentials"
688            }),
689            // Script-based example
690            json!({
691                "type": "open_server",
692                "port": 110,
693                "base_stack": "pop3",
694                "event_handlers": [{
695                    "event_pattern": "pop3_command",
696                    "handler": {
697                        "type": "script",
698                        "language": "python",
699                        "code": "# Handle POP3 commands\ncmd = event.get('command', '').upper()\nif cmd.startswith('USER'):\n    respond([{'type': 'send_pop3_ok', 'message': 'user accepted'}])\nelif cmd.startswith('PASS'):\n    respond([{'type': 'send_pop3_ok', 'message': 'logged in, 3 messages'}])\nelif cmd == 'STAT':\n    respond([{'type': 'send_pop3_stat', 'message_count': 3, 'total_size': 1024}])\nelif cmd == 'LIST':\n    respond([{'type': 'send_pop3_list', 'messages': [{'id': 1, 'size': 512}, {'id': 2, 'size': 256}, {'id': 3, 'size': 256}]}])\nelif cmd == 'QUIT':\n    respond([{'type': 'send_pop3_ok', 'message': 'bye'}])\nelse:\n    respond([{'type': 'send_pop3_ok'}])"
700                    }
701                }]
702            }),
703            // Static handler example
704            json!({
705                "type": "open_server",
706                "port": 110,
707                "base_stack": "pop3",
708                "event_handlers": [{
709                    "event_pattern": "pop3_command",
710                    "handler": {
711                        "type": "static",
712                        "actions": [{
713                            "type": "send_pop3_ok",
714                            "message": "OK"
715                        }]
716                    }
717                }]
718            }),
719        )
720    }
721}
722
723// Implement Server trait (server-specific functionality)
724impl Server for Pop3Protocol {
725    fn spawn(
726        &self,
727        ctx: crate::protocol::SpawnContext,
728    ) -> std::pin::Pin<
729        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
730    > {
731        Box::pin(async move {
732            use crate::server::pop3::Pop3Server;
733
734            // POP3S (implicit TLS) is not reachable yet. `Pop3Server` already implements the
735            // whole TLS path and takes the config, but building one needs
736            // `crate::server::tls_cert_manager`, which `src/server/mod.rs` gates on
737            // `dot`/`doh`/`http`/`smtp`/`tls` and not on `pop3`. Adding `feature = "pop3"` to
738            // that cfg list is all this needs; until then POP3 declares no TLS startup
739            // parameters rather than accepting ones it silently drops.
740            let tls_config = None;
741
742            // Both read deadlines are the operator's to choose: who is on the other end is
743            // the only thing that decides them, and only the operator knows that.
744            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
745                Ok(ctx
746                    .startup_params
747                    .as_ref()
748                    .map(|p| p.get_optional_u64(name))
749                    .transpose()?
750                    .flatten())
751            };
752            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
753            let idle_timeout_secs = secs("idle_timeout_secs")?;
754
755            Pop3Server::spawn_with_llm_actions(
756                ctx.legacy_listen_addr(),
757                ctx.llm_client,
758                ctx.state,
759                ctx.status_tx,
760                ctx.server_id,
761                tls_config,
762                first_byte_timeout_secs,
763                idle_timeout_secs,
764            )
765            .await
766        })
767    }
768
769    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
770        let action_type = action
771            .get("type")
772            .and_then(|v| v.as_str())
773            .context("Missing 'type' field in action")?;
774
775        match action_type {
776            "send_pop3_greeting" => self.execute_send_pop3_greeting(action),
777            "send_pop3_ok" => self.execute_send_pop3_ok(action),
778            "send_pop3_err" => self.execute_send_pop3_err(action),
779            "send_pop3_stat" => self.execute_send_pop3_stat(action),
780            "send_pop3_list" => self.execute_send_pop3_list(action),
781            "send_pop3_uidl" => self.execute_send_pop3_uidl(action),
782            "send_pop3_retr" => self.execute_send_pop3_retr(action),
783            "send_pop3_top" => self.execute_send_pop3_top(action),
784            "send_pop3_message" => self.execute_send_pop3_message(action),
785            "wait_for_more" => Ok(ActionResult::WaitForMore),
786            "close_connection" => Ok(ActionResult::CloseConnection),
787            _ => Err(anyhow::anyhow!("Unknown POP3 action: {}", action_type)),
788        }
789    }
790}