Skip to main content

netget/server/smtp/
actions.rs

1//! SMTP protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13use tracing::debug;
14
15/// SMTP protocol action handler
16pub struct SmtpProtocol;
17
18impl SmtpProtocol {
19    pub fn new() -> Self {
20        Self
21    }
22
23    fn execute_send_smtp_greeting(&self, action: serde_json::Value) -> Result<ActionResult> {
24        let hostname = action
25            .get("hostname")
26            .and_then(|v| v.as_str())
27            .unwrap_or("localhost");
28
29        let message = action
30            .get("message")
31            .and_then(|v| v.as_str())
32            .unwrap_or("ESMTP Service Ready");
33
34        let response = format!("220 {} {}\r\n", hostname, message);
35
36        debug!("SMTP sending greeting: {}", response.trim());
37        Ok(ActionResult::Output(response.as_bytes().to_vec()))
38    }
39
40    fn execute_send_smtp_ok(&self, action: serde_json::Value) -> Result<ActionResult> {
41        let message = action
42            .get("message")
43            .and_then(|v| v.as_str())
44            .unwrap_or("OK");
45
46        let response = format!("250 {}\r\n", message);
47
48        debug!("SMTP sending OK: {}", message);
49        Ok(ActionResult::Output(response.as_bytes().to_vec()))
50    }
51
52    fn execute_send_smtp_start_data(&self, _action: serde_json::Value) -> Result<ActionResult> {
53        let response = "354 Start mail input; end with <CRLF>.<CRLF>\r\n";
54
55        debug!("SMTP sending start data");
56        Ok(ActionResult::Output(response.as_bytes().to_vec()))
57    }
58
59    fn execute_send_smtp_error(&self, action: serde_json::Value) -> Result<ActionResult> {
60        let code = action.get("code").and_then(|v| v.as_u64()).unwrap_or(500);
61
62        let message = action
63            .get("message")
64            .and_then(|v| v.as_str())
65            .context("Missing 'message' parameter")?;
66
67        let response = format!("{} {}\r\n", code, message);
68
69        debug!("SMTP sending error {}: {}", code, message);
70        Ok(ActionResult::Output(response.as_bytes().to_vec()))
71    }
72
73    fn execute_send_smtp_quit(&self, action: serde_json::Value) -> Result<ActionResult> {
74        let hostname = action
75            .get("hostname")
76            .and_then(|v| v.as_str())
77            .unwrap_or("localhost");
78
79        let response = format!("221 {} closing connection\r\n", hostname);
80
81        debug!("SMTP sending QUIT response");
82        Ok(ActionResult::Output(response.as_bytes().to_vec()))
83    }
84
85    fn execute_send_smtp_message(&self, action: serde_json::Value) -> Result<ActionResult> {
86        let message = action
87            .get("message")
88            .and_then(|v| v.as_str())
89            .context("Missing 'message' parameter")?;
90
91        // Ensure message ends with \r\n
92        let formatted = if message.ends_with("\r\n") {
93            message.to_string()
94        } else if message.ends_with('\n') {
95            format!("{}\r", message.trim_end_matches('\n'))
96        } else {
97            format!("{}\r\n", message)
98        };
99
100        debug!("SMTP sending custom message: {}", formatted.trim());
101        Ok(ActionResult::Output(formatted.as_bytes().to_vec()))
102    }
103}
104
105// Implement Protocol trait (common functionality)
106impl Protocol for SmtpProtocol {
107    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
108        use crate::llm::actions::ParameterDefinition;
109        vec![
110                ParameterDefinition {
111                    name: "enable_tls".to_string(),
112                    type_hint: "boolean".to_string(),
113                    description: "Enable SMTPS (implicit TLS) mode (default: false)".to_string(),
114                    required: false,
115                    example: json!(true),
116                    default: None,
117                },
118                ParameterDefinition {
119                    name: "tls_common_name".to_string(),
120                    type_hint: "string".to_string(),
121                    description: "TLS certificate Common Name (CN) (default: 'netget-smtp-server')".to_string(),
122                    required: false,
123                    example: json!("mail.example.com"),
124                    default: None,
125                },
126                ParameterDefinition {
127                    name: "tls_san_dns_names".to_string(),
128                    type_hint: "array".to_string(),
129                    description: "TLS certificate Subject Alternative Names (DNS names) (default: ['localhost', '*.local'])".to_string(),
130                    required: false,
131                    example: json!(["mail.example.com", "localhost", "*.example.com"]),
132                    default: None,
133                },
134                ParameterDefinition {
135                    name: "tls_validity_days".to_string(),
136                    type_hint: "integer".to_string(),
137                    description: "TLS certificate validity period in days (default: 365)".to_string(),
138                    required: false,
139                    example: json!(365),
140                    default: None,
141                },
142                ParameterDefinition {
143                    name: "tls_organization".to_string(),
144                    type_hint: "string".to_string(),
145                    description: "TLS certificate Organization (O) (default: 'NetGet')".to_string(),
146                    required: false,
147                    example: json!("Example Corp"),
148                    default: None,
149                },
150                ParameterDefinition {
151                    name: "tls_organizational_unit".to_string(),
152                    type_hint: "string".to_string(),
153                    description: "TLS certificate Organizational Unit (OU) (default: 'SMTP Server')".to_string(),
154                    required: false,
155                    example: json!("IT Department"),
156                    default: None,
157                },
158            ]
159    }
160    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
161        // SMTP doesn't need async actions for now
162        Vec::new()
163    }
164    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
165        vec![
166            send_smtp_greeting_action(),
167            send_smtp_ok_action(),
168            send_smtp_start_data_action(),
169            send_smtp_error_action(),
170            send_smtp_quit_action(),
171            send_smtp_message_action(),
172            wait_for_more_action(),
173            close_connection_action(),
174        ]
175    }
176    fn protocol_name(&self) -> &'static str {
177        "SMTP"
178    }
179    fn get_event_types(&self) -> Vec<EventType> {
180        get_smtp_event_types()
181    }
182    fn stack_name(&self) -> &'static str {
183        "ETH>IP>TCP>SMTP"
184    }
185    fn keywords(&self) -> Vec<&'static str> {
186        vec!["smtp", "mail", "email"]
187    }
188    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
189        use crate::protocol::metadata::{
190            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
191        };
192
193        ProtocolMetadataV2::builder()
194            .state(DevelopmentState::Experimental)
195            .implementation(
196                "Manual line-based parsing with tokio, optional implicit TLS via rustls",
197            )
198            .llm_control("All SMTP commands + responses")
199            .e2e_testing("Raw TCP client driving the SMTP command sequence")
200            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(25))
201            .well_known_port(25)
202            .notes(
203                "Accepts mail but stores nothing - the model answers every command. No AUTH, no \
204                 STARTTLS, no PIPELINING. Every DATA body line costs one model call unless an \
205                 event handler is configured.",
206            )
207            .max_inbound_bytes(crate::server::smtp::MAX_LINE_BYTES)
208            .build()
209    }
210    fn description(&self) -> &'static str {
211        "SMTP/SMTPS mail server"
212    }
213    fn example_prompt(&self) -> &'static str {
214        "Start an SMTP mail server on port 25 (or 'Start an SMTPS mail server on port 465 with TLS enabled')"
215    }
216    fn group_name(&self) -> &'static str {
217        "Application"
218    }
219    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
220        use crate::llm::actions::StartupExamples;
221        // Deterministic SMTP state machine: standard replies for each command
222        // verb. Reads the event from stdin and switches on event_type_id. EHLO and
223        // HELO never reach a handler: NetGet answers them itself.
224        let script = r#"import json, sys
225data = json.load(sys.stdin)
226event = data["event"]
227if data["event_type_id"] == "smtp_command":
228    cmd = event.get("command", "").upper()
229    if cmd == "CONNECTION_ESTABLISHED":
230        actions = [{"type": "send_smtp_greeting", "hostname": "mail.example.com"}]
231    elif cmd.startswith("MAIL FROM"):
232        actions = [{"type": "send_smtp_ok", "message": "Sender OK"}]
233    elif cmd.startswith("RCPT TO"):
234        actions = [{"type": "send_smtp_ok", "message": "Recipient OK"}]
235    elif cmd == "DATA":
236        actions = [{"type": "send_smtp_start_data"}]
237    elif cmd == "QUIT":
238        actions = [{"type": "send_smtp_quit"}]
239    else:
240        actions = [{"type": "send_smtp_ok"}]
241else:
242    actions = []
243print(json.dumps({"actions": actions}))"#;
244
245        StartupExamples::new(
246            // LLM mode: policy decisions per recipient — real reasoning.
247            json!({
248                "type": "open_server",
249                "port": 25,
250                "base_stack": "smtp",
251                "instruction": "Act as the SMTP server for example.com: complete the SMTP handshake, accept mail only for recipients @example.com and reject any other recipient with a 550 error, and note the subject line of each accepted message."
252            }),
253            // Script mode: standard SMTP flow with fixed replies, no LLM call.
254            json!({
255                "type": "open_server",
256                "port": 25,
257                "base_stack": "smtp",
258                "event_handlers": [{
259                    "event_pattern": "smtp_command",
260                    "handler": {
261                        "type": "script",
262                        "language": "python",
263                        "code": script
264                    }
265                }]
266            }),
267            // Static handler example
268            json!({
269                "type": "open_server",
270                "port": 25,
271                "base_stack": "smtp",
272                "event_handlers": [{
273                    "event_pattern": "smtp_command",
274                    "handler": {
275                        "type": "static",
276                        "actions": [{
277                            "type": "send_smtp_ok",
278                            "message": "OK"
279                        }]
280                    }
281                }]
282            }),
283        )
284    }
285}
286
287// Implement Server trait (server-specific functionality)
288impl Server for SmtpProtocol {
289    fn spawn(
290        &self,
291        ctx: crate::protocol::SpawnContext,
292    ) -> std::pin::Pin<
293        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
294    > {
295        Box::pin(async move {
296            use crate::server::smtp::SmtpServer;
297
298            // Check if TLS should be enabled via startup parameters
299            let tls_config = if let Some(ref params) = ctx.startup_params {
300                if params.get_optional_bool("enable_tls")?.unwrap_or(false) {
301                    // Generate TLS configuration
302                    let common_name = params.get_optional_string("tls_common_name")?;
303                    let san_dns_names =
304                        params.get_optional_array("tls_san_dns_names")?.map(|arr| {
305                            arr.iter()
306                                .filter_map(|v| v.as_str())
307                                .map(|s| s.to_string())
308                                .collect::<Vec<_>>()
309                        });
310                    let validity_days = params.get_optional_i64("tls_validity_days")?;
311                    let organization = params.get_optional_string("tls_organization")?;
312                    let organizational_unit =
313                        params.get_optional_string("tls_organizational_unit")?;
314
315                    // Fail the spawn rather than falling back to plain text. Logging the error
316                    // and returning None handed a caller who explicitly asked for SMTPS a
317                    // cleartext mail port that reported itself as Running.
318                    Some(
319                        crate::server::tls_cert_manager::generate_custom_tls_config(
320                            common_name,
321                            san_dns_names,
322                            validity_days,
323                            organization,
324                            organizational_unit,
325                        )
326                        .context("enable_tls was requested but the SMTPS certificate could not be generated")?,
327                    )
328                } else {
329                    None
330                }
331            } else {
332                None
333            };
334
335            SmtpServer::spawn_with_llm_actions(
336                ctx.legacy_listen_addr(),
337                ctx.llm_client,
338                ctx.state,
339                ctx.status_tx,
340                ctx.server_id,
341                tls_config,
342            )
343            .await
344        })
345    }
346    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
347        let action_type = action
348            .get("type")
349            .and_then(|v| v.as_str())
350            .context("Missing 'type' field in action")?;
351
352        match action_type {
353            "send_smtp_greeting" => self.execute_send_smtp_greeting(action),
354            "send_smtp_ok" => self.execute_send_smtp_ok(action),
355            "send_smtp_start_data" => self.execute_send_smtp_start_data(action),
356            "send_smtp_error" => self.execute_send_smtp_error(action),
357            "send_smtp_quit" => self.execute_send_smtp_quit(action),
358            "send_smtp_message" => self.execute_send_smtp_message(action),
359            "wait_for_more" => Ok(ActionResult::WaitForMore),
360            "close_connection" => Ok(ActionResult::CloseConnection),
361            _ => Err(anyhow::anyhow!("Unknown SMTP action: {}", action_type)),
362        }
363    }
364}
365
366// Action definitions
367
368fn send_smtp_greeting_action() -> ActionDefinition {
369    ActionDefinition {
370        name: "send_smtp_greeting".to_string(),
371        description: "The 220 banner, sent once when a client connects (the \
372                      CONNECTION_ESTABLISHED event) and never in answer to a command. The \
373                      line is '220 <hostname> <message>'. When your instruction names a \
374                      banner, that text goes in message, word for word."
375            .to_string(),
376        parameters: vec![
377            Parameter {
378                name: "hostname".to_string(),
379                type_hint: "string".to_string(),
380                description: "Server hostname (default: localhost)".to_string(),
381                required: false,
382            },
383            Parameter {
384                name: "message".to_string(),
385                type_hint: "string".to_string(),
386                description: "Banner text after the hostname: the banner your instruction \
387                              gives, verbatim (default: 'ESMTP Service Ready')"
388                    .to_string(),
389                required: false,
390            },
391        ],
392        example: json!({
393            "type": "send_smtp_greeting",
394            "hostname": "mx.example.invalid",
395            "message": "ESMTP"
396        }),
397        log_template: Some(
398            LogTemplate::new()
399                .with_info("-> SMTP 220 {hostname}")
400                .with_debug("SMTP send_smtp_greeting: {hostname}"),
401        ),
402    }
403}
404
405fn send_smtp_ok_action() -> ActionDefinition {
406    ActionDefinition {
407        name: "send_smtp_ok".to_string(),
408        description: "A 250 reply: accepts a MAIL FROM sender, a RCPT TO recipient, the \
409                      end of a message ('.'), RSET or NOOP. Never the answer to \
410                      CONNECTION_ESTABLISHED - that is send_smtp_greeting."
411            .to_string(),
412        parameters: vec![Parameter {
413            name: "message".to_string(),
414            type_hint: "string".to_string(),
415            description: "OK message (default: 'OK')".to_string(),
416            required: false,
417        }],
418        example: json!({
419            "type": "send_smtp_ok",
420            "message": "Requested mail action okay, completed"
421        }),
422        log_template: Some(
423            LogTemplate::new()
424                .with_info("-> SMTP 250 {message}")
425                .with_debug("SMTP send_smtp_ok: {message}"),
426        ),
427    }
428}
429
430fn send_smtp_start_data_action() -> ActionDefinition {
431    ActionDefinition {
432        name: "send_smtp_start_data".to_string(),
433        description: "Send SMTP start data response (354)".to_string(),
434        parameters: vec![],
435        example: json!({
436            "type": "send_smtp_start_data"
437        }),
438        log_template: Some(
439            LogTemplate::new()
440                .with_info("-> SMTP 354 Start data")
441                .with_debug("SMTP send_smtp_start_data"),
442        ),
443    }
444}
445
446fn send_smtp_error_action() -> ActionDefinition {
447    ActionDefinition {
448        name: "send_smtp_error".to_string(),
449        description: "A 4xx/5xx refusal. 550 refuses a recipient (RCPT TO) or sender \
450                      (MAIL FROM) the instruction does not accept; 500 is an unrecognised \
451                      command."
452            .to_string(),
453        parameters: vec![
454            Parameter {
455                name: "code".to_string(),
456                type_hint: "number".to_string(),
457                description: "SMTP error code (e.g., 550, 500) (default: 500)".to_string(),
458                required: false,
459            },
460            Parameter {
461                name: "message".to_string(),
462                type_hint: "string".to_string(),
463                description: "Error message".to_string(),
464                required: true,
465            },
466        ],
467        example: json!({
468            "type": "send_smtp_error",
469            "code": 550,
470            "message": "Mailbox unavailable"
471        }),
472        log_template: Some(
473            LogTemplate::new()
474                .with_info("-> SMTP {code} {message}")
475                .with_debug("SMTP send_smtp_error: {code} {message}"),
476        ),
477    }
478}
479
480fn send_smtp_quit_action() -> ActionDefinition {
481    ActionDefinition {
482        name: "send_smtp_quit".to_string(),
483        description: "Send SMTP QUIT response (221) and prepare to close".to_string(),
484        parameters: vec![Parameter {
485            name: "hostname".to_string(),
486            type_hint: "string".to_string(),
487            description: "Server hostname (default: localhost)".to_string(),
488            required: false,
489        }],
490        example: json!({
491            "type": "send_smtp_quit",
492            "hostname": "mail.example.com"
493        }),
494        log_template: Some(
495            LogTemplate::new()
496                .with_info("-> SMTP 221 closing")
497                .with_debug("SMTP send_smtp_quit: {hostname}"),
498        ),
499    }
500}
501
502fn send_smtp_message_action() -> ActionDefinition {
503    ActionDefinition {
504        name: "send_smtp_message".to_string(),
505        description: "Send a custom SMTP message (raw)".to_string(),
506        parameters: vec![Parameter {
507            name: "message".to_string(),
508            type_hint: "string".to_string(),
509            description: "SMTP message (will auto-add \\r\\n if not present)".to_string(),
510            required: true,
511        }],
512        example: json!({
513            "type": "send_smtp_message",
514            "message": "250 2.1.0 Sender OK"
515        }),
516        log_template: Some(
517            LogTemplate::new()
518                .with_info("-> SMTP {message}")
519                .with_debug("SMTP send_smtp_message: {message}"),
520        ),
521    }
522}
523
524fn wait_for_more_action() -> ActionDefinition {
525    ActionDefinition {
526        name: "wait_for_more".to_string(),
527        description: "Wait for more data before responding".to_string(),
528        parameters: vec![],
529        example: json!({
530            "type": "wait_for_more"
531        }),
532        log_template: Some(LogTemplate::new().with_debug("SMTP waiting for more data")),
533    }
534}
535
536fn close_connection_action() -> ActionDefinition {
537    ActionDefinition {
538        name: "close_connection".to_string(),
539        description: "Close the SMTP connection".to_string(),
540        parameters: vec![],
541        example: json!({
542            "type": "close_connection"
543        }),
544        log_template: Some(
545            LogTemplate::new()
546                .with_info("SMTP connection closed")
547                .with_debug("SMTP close_connection"),
548        ),
549    }
550}
551
552// ============================================================================
553// SMTP Action Constants
554// ============================================================================
555
556pub static SEND_SMTP_GREETING_ACTION: LazyLock<ActionDefinition> =
557    LazyLock::new(|| send_smtp_greeting_action());
558pub static SEND_SMTP_OK_ACTION: LazyLock<ActionDefinition> =
559    LazyLock::new(|| send_smtp_ok_action());
560pub static SEND_SMTP_START_DATA_ACTION: LazyLock<ActionDefinition> =
561    LazyLock::new(|| send_smtp_start_data_action());
562pub static SEND_SMTP_ERROR_ACTION: LazyLock<ActionDefinition> =
563    LazyLock::new(|| send_smtp_error_action());
564pub static SEND_SMTP_QUIT_ACTION: LazyLock<ActionDefinition> =
565    LazyLock::new(|| send_smtp_quit_action());
566pub static SEND_SMTP_MESSAGE_ACTION: LazyLock<ActionDefinition> =
567    LazyLock::new(|| send_smtp_message_action());
568pub static WAIT_FOR_MORE_ACTION: LazyLock<ActionDefinition> =
569    LazyLock::new(|| wait_for_more_action());
570pub static CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
571    LazyLock::new(|| close_connection_action());
572
573// ============================================================================
574// SMTP Event Type Constants
575// ============================================================================
576
577/// SMTP command event - triggered when client sends an SMTP command
578pub static SMTP_COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
579    EventType::new(
580        "smtp_command",
581        "SMTP command line received from client. The synthetic command \
582         'CONNECTION_ESTABLISHED' is delivered once when a client connects and is answered \
583         with send_smtp_greeting; no later command is. EHLO and HELO are answered by NetGet \
584         and never reach you. Each command gets exactly one reply, and answer_with names the \
585         action for this one. During DATA every line of the message body arrives as its own \
586         event, ending with a line containing only '.'",
587        json!({"type": "send_smtp_ok", "message": "2.1.0 Sender OK"}),
588    )
589    .with_parameters(vec![
590        Parameter {
591            name: "command".to_string(),
592            type_hint: "string".to_string(),
593            description: "The SMTP command received (e.g. 'MAIL FROM:<sender@example.com>'), \
594                          or 'CONNECTION_ESTABLISHED' on a new connection"
595                .to_string(),
596            required: true,
597        },
598        Parameter {
599            name: "answer_with".to_string(),
600            type_hint: "string".to_string(),
601            description: "Which action answers this command, and with which reply code".to_string(),
602            required: false,
603        },
604        Parameter {
605            name: "address".to_string(),
606            type_hint: "string".to_string(),
607            description: "MAIL FROM / RCPT TO only: the mailbox inside the angle brackets"
608                .to_string(),
609            required: false,
610        },
611        Parameter {
612            name: "domain".to_string(),
613            type_hint: "string".to_string(),
614            description: "MAIL FROM / RCPT TO only: the part of the address after '@'".to_string(),
615            required: false,
616        },
617    ])
618    .with_actions(vec![
619        SEND_SMTP_GREETING_ACTION.clone(),
620        SEND_SMTP_OK_ACTION.clone(),
621        SEND_SMTP_START_DATA_ACTION.clone(),
622        SEND_SMTP_ERROR_ACTION.clone(),
623        SEND_SMTP_QUIT_ACTION.clone(),
624        SEND_SMTP_MESSAGE_ACTION.clone(),
625        WAIT_FOR_MORE_ACTION.clone(),
626        CLOSE_CONNECTION_ACTION.clone(),
627    ])
628    .with_log_template(
629        LogTemplate::new()
630            .with_info("SMTP: {command}")
631            .with_debug("SMTP command: {command}")
632            .with_trace("SMTP: {json_pretty(.)}"),
633    )
634});
635
636/// Get SMTP event types
637pub fn get_smtp_event_types() -> Vec<EventType> {
638    vec![SMTP_COMMAND_EVENT.clone()]
639}