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}