Skip to main content

netget/server/lmtp/
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, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct LmtpProtocol;
14impl LmtpProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28
29pub fn action(
30    name: &str,
31    description: &str,
32    parameters: Vec<Parameter>,
33    example: Value,
34) -> ActionDefinition {
35    let log_template = match name {
36        "lmtp_recipient_reply" => {
37            LogTemplate::new().with_info("-> LMTP recipient accept={accept} temporary={temporary}")
38        }
39        "lmtp_delivery" => LogTemplate::new()
40            .with_info("-> LMTP delivery deliver_all={deliver_all} results={preview(results,120)}"),
41        "lmtp_send" => {
42            LogTemplate::new().with_info("-> LMTP send from={from} to={preview(to,120)}")
43        }
44        "disconnect" => LogTemplate::new().with_info("-> LMTP QUIT"),
45        _ => LogTemplate::new().with_info(format!("-> LMTP {name}")),
46    };
47    ActionDefinition {
48        name: name.into(),
49        description: description.into(),
50        parameters,
51        example,
52        log_template: Some(log_template),
53    }
54}
55
56fn recipient_action() -> ActionDefinition {
57    action(
58        "lmtp_recipient_reply",
59        "Accept or refuse one RCPT TO. Refusal is permanent (550) unless temporary is true (450).",
60        vec![
61            parameter("accept", "boolean", "True to accept this recipient", true),
62            parameter(
63                "temporary",
64                "boolean",
65                "With accept false: a temporary failure the sender should retry",
66                false,
67            ),
68            parameter("reason", "string", "Human-readable reply text", false),
69        ],
70        json!({"type":"lmtp_recipient_reply","accept":true}),
71    )
72}
73
74fn delivery_action() -> ActionDefinition {
75    action(
76        "lmtp_delivery",
77        "Report the delivery outcome for each accepted recipient of the message. LMTP answers \
78         DATA once per recipient, in RCPT order; a recipient you do not list gets deliver_all, \
79         or a temporary failure (451) when deliver_all is absent.",
80        vec![
81            parameter(
82                "results",
83                "array",
84                "[{recipient, delivered: bool, temporary?: bool, reason?: string}]",
85                false,
86            ),
87            parameter(
88                "deliver_all",
89                "boolean",
90                "Outcome for recipients not listed in results: true delivers them, false refuses them permanently",
91                false,
92            ),
93        ],
94        json!({"type":"lmtp_delivery","deliver_all":true}),
95    )
96}
97
98pub static RECIPIENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
99    EventType::new(
100        "lmtp_recipient",
101        "A sender named a recipient with RCPT TO; decide whether this mailbox exists here.",
102        recipient_action().example.clone(),
103    )
104    .with_parameters(vec![
105        parameter("recipient", "string", "Address from RCPT TO", true),
106        parameter(
107            "mail_from",
108            "string",
109            "Reverse path from MAIL FROM (may be empty)",
110            true,
111        ),
112        parameter("lhlo", "string", "Domain the client gave in LHLO", true),
113        parameter(
114            "accepted_so_far",
115            "number",
116            "Recipients already accepted in this transaction",
117            true,
118        ),
119    ])
120    .with_actions(vec![recipient_action()])
121});
122
123pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
124    EventType::new(
125        "lmtp_message",
126        "A complete message after DATA, for the recipients you accepted. Decide the outcome per recipient.",
127        delivery_action().example.clone(),
128    )
129    .with_parameters(vec![
130        parameter("mail_from", "string", "Reverse path from MAIL FROM", true),
131        parameter("recipients", "array", "Accepted recipients, in RCPT order", true),
132        parameter("size", "number", "Message size in bytes after dot-unstuffing", true),
133        parameter("headers", "object", "Header fields by lowercase name (first occurrence)", true),
134        parameter("subject", "string", "Subject header, if any", false),
135        parameter("body", "string", "Body text after the header block, at most 64 KiB", true),
136        parameter("body_truncated", "boolean", "True when the body was longer than shown", true),
137    ])
138    .with_actions(vec![delivery_action()])
139});
140
141impl Protocol for LmtpProtocol {
142    fn protocol_name(&self) -> &'static str {
143        "LMTP"
144    }
145    fn stack_name(&self) -> &'static str {
146        "ETH>IP>TCP>LMTP"
147    }
148    fn description(&self) -> &'static str {
149        "LMTP local delivery server (RFC 2033) with per-recipient delivery decisions"
150    }
151    fn keywords(&self) -> Vec<&'static str> {
152        vec!["lmtp", "rfc2033", "lhlo", "mail delivery", "mda"]
153    }
154    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
155        vec![]
156    }
157    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
158        vec![recipient_action(), delivery_action()]
159    }
160    fn get_event_types(&self) -> Vec<EventType> {
161        vec![RECIPIENT_EVENT.clone(), MESSAGE_EVENT.clone()]
162    }
163    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
164        vec![
165            ParameterDefinition {
166                name: "hostname".into(),
167                type_hint: "string".into(),
168                description: "Name in the 220 greeting and the LHLO reply".into(),
169                required: false,
170                example: json!("mail.example.test"),
171                default: Some(json!(super::DEFAULT_HOSTNAME)),
172            },
173            ParameterDefinition {
174                name: "max_message_bytes".into(),
175                type_hint: "number".into(),
176                description: "Largest message accepted, advertised as SIZE (1..=67108864)".into(),
177                required: false,
178                example: json!(1048576),
179                default: Some(json!(super::wire::DEFAULT_MAX_MESSAGE_BYTES)),
180            },
181            ParameterDefinition {
182                name: "idle_timeout_secs".into(),
183                type_hint: "number".into(),
184                description: "Seconds to wait for each command or DATA line (1..=3600)".into(),
185                required: false,
186                example: json!(300),
187                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
188            },
189        ]
190    }
191    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
192        use crate::protocol::metadata::*;
193        ProtocolMetadataV2::builder()
194            .state(DevelopmentState::Experimental)
195            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(24))
196            .well_known_port(24)
197            .implementation("RFC 2033 over Tokio TCP: LHLO, MAIL, RCPT, DATA with one reply per accepted recipient, RSET, NOOP, VRFY, QUIT; PIPELINING, ENHANCEDSTATUSCODES, 8BITMIME and SIZE")
198            .llm_control("Whether each recipient exists (RCPT), and the per-recipient delivery outcome of each message (DATA)")
199            .e2e_testing("tests/server/lmtp: raw-wire sessions and bounds; CPython smtplib.LMTP and swaks --protocol LMTP as independent clients")
200            .notes("Plain TCP only: no STARTTLS or AUTH. No mailbox storage: the handler decides delivery and keeps whatever it needs in memory or the SQLite facility. HELO/EHLO are refused as RFC 2033 requires. Lines are 1000 bytes, 100 recipients per transaction, 10 MiB messages by default, 20 consecutive bad commands close the session with 421. A handler failure answers 451 4.3.0, never a false delivery.")
201            .request_only("LMTP replies answer the command just read; the server never speaks first except for its greeting")
202            .max_inbound_bytes(super::wire::MAX_LINE_BYTES)
203            .build()
204    }
205    fn example_prompt(&self) -> &'static str {
206        "LMTP server on port 2424 that accepts mail for alice@example.test and bob@example.test"
207    }
208    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
209        let llm = json!({"type":"open_server","base_stack":"lmtp","port":2424,"instruction":"Accept mail for alice@example.test only"});
210        let mut scripted = llm.clone();
211        scripted["event_handlers"] = json!([
212            {"event_pattern":"lmtp_recipient","handler":{"type":"script","language":"python","code":"import json,sys\ni=json.load(sys.stdin)\nok=i['event']['recipient'].endswith('@example.test')\nprint(json.dumps({'actions':[{'type':'lmtp_recipient_reply','accept':ok}]}))"}},
213            {"event_pattern":"lmtp_message","handler":{"type":"static","actions":[{"type":"lmtp_delivery","deliver_all":true}]}}
214        ]);
215        let mut static_example = llm.clone();
216        static_example["event_handlers"] = json!([
217            {"event_pattern":"lmtp_recipient","handler":{"type":"static","actions":[{"type":"lmtp_recipient_reply","accept":true}]}},
218            {"event_pattern":"lmtp_message","handler":{"type":"static","actions":[{"type":"lmtp_delivery","deliver_all":true}]}}
219        ]);
220        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
221    }
222    fn group_name(&self) -> &'static str {
223        "Application"
224    }
225}
226
227impl Server for LmtpProtocol {
228    fn spawn(
229        &self,
230        ctx: SpawnContext,
231    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
232    {
233        Box::pin(super::spawn(ctx))
234    }
235    fn execute_action(&self, v: Value) -> Result<ActionResult> {
236        match v["type"].as_str() {
237            Some("lmtp_recipient_reply") => {
238                ensure!(v["accept"].is_boolean(), "accept must be a boolean");
239                ensure!(
240                    v.get("temporary").is_none_or(Value::is_boolean),
241                    "temporary must be a boolean"
242                );
243                ensure!(
244                    v.get("reason").is_none_or(Value::is_string),
245                    "reason must be a string"
246                );
247                Ok(ActionResult::Custom {
248                    name: "lmtp_recipient_reply".into(),
249                    data: v,
250                })
251            }
252            Some("lmtp_delivery") => {
253                ensure!(
254                    v.get("deliver_all").is_none_or(Value::is_boolean),
255                    "deliver_all must be a boolean"
256                );
257                if let Some(results) = v.get("results") {
258                    let results = results
259                        .as_array()
260                        .ok_or_else(|| anyhow::anyhow!("results must be an array"))?;
261                    ensure!(
262                        results.len() <= super::wire::MAX_RECIPIENTS,
263                        "results lists more recipients than a transaction can hold"
264                    );
265                    for result in results {
266                        ensure!(
267                            result["recipient"].is_string(),
268                            "each result needs a recipient"
269                        );
270                        ensure!(
271                            result["delivered"].is_boolean(),
272                            "each result needs delivered"
273                        );
274                    }
275                }
276                Ok(ActionResult::Custom {
277                    name: "lmtp_delivery".into(),
278                    data: v,
279                })
280            }
281            _ => bail!("Unknown LMTP server action"),
282        }
283    }
284}