1use 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
15pub 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 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
105impl 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 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 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 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 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 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
287impl 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 let tls_config = if let Some(ref params) = ctx.startup_params {
300 if params.get_optional_bool("enable_tls")?.unwrap_or(false) {
301 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 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
366fn 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
552pub 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
573pub 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
636pub fn get_smtp_event_types() -> Vec<EventType> {
638 vec![SMTP_COMMAND_EVENT.clone()]
639}