1pub mod actions;
5pub mod wire;
6
7use crate::llm::action_helper::call_llm;
8use crate::llm::actions::protocol_trait::ActionResult;
9use crate::logging::emit::Log;
10use crate::protocol::{Event, SpawnContext};
11use crate::server::connection::ConnectionId;
12use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
13use anyhow::{Context, Result};
14use serde_json::{json, Value};
15use std::{collections::HashMap, net::SocketAddr, time::Duration};
16use tokio::{
17 io::{AsyncWriteExt, BufReader},
18 net::{TcpListener, TcpStream},
19};
20use wire::Line;
21
22pub const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
24pub const MAX_IDLE_TIMEOUT_SECS: u64 = 3600;
25pub const DEFAULT_HOSTNAME: &str = "netget";
26pub const MAX_BAD_COMMANDS: usize = 20;
28
29#[derive(Clone)]
30struct Config {
31 hostname: String,
32 max_message: u64,
33 idle: Duration,
34}
35
36fn config(ctx: &SpawnContext) -> Result<Config> {
37 let params = ctx.startup_params.as_ref();
38 let hostname = params
39 .map(|p| p.get_optional_string("hostname"))
40 .transpose()?
41 .flatten()
42 .unwrap_or_else(|| DEFAULT_HOSTNAME.to_string());
43 anyhow::ensure!(
44 !hostname.is_empty()
45 && hostname.len() <= 255
46 && hostname.chars().all(|c| c.is_ascii_graphic()),
47 "hostname must be 1..=255 printable ASCII characters without spaces"
48 );
49 let max_message = params
50 .map(|p| p.get_optional_u64("max_message_bytes"))
51 .transpose()?
52 .flatten()
53 .unwrap_or(wire::DEFAULT_MAX_MESSAGE_BYTES);
54 anyhow::ensure!(
55 (1..=wire::MAX_MESSAGE_BYTES_LIMIT).contains(&max_message),
56 "max_message_bytes must be between 1 and {}",
57 wire::MAX_MESSAGE_BYTES_LIMIT
58 );
59 let idle = params
60 .map(|p| p.get_optional_u64("idle_timeout_secs"))
61 .transpose()?
62 .flatten()
63 .unwrap_or(IDLE_TIMEOUT.as_secs());
64 anyhow::ensure!(
65 (1..=MAX_IDLE_TIMEOUT_SECS).contains(&idle),
66 "idle_timeout_secs must be between 1 and {MAX_IDLE_TIMEOUT_SECS}"
67 );
68 Ok(Config {
69 hostname,
70 max_message,
71 idle: Duration::from_secs(idle),
72 })
73}
74
75pub async fn spawn(ctx: SpawnContext) -> Result<SocketAddr> {
76 let cfg = config(&ctx)?;
77 let listener = TcpListener::bind(ctx.legacy_listen_addr()).await?;
78 let addr = listener.local_addr()?;
79 Log::new(Some(&ctx.status_tx)).info(format!("LMTP listening on {addr}"));
80 let state = ctx.state.clone();
81 let server_id = ctx.server_id;
82 let accept = tokio::spawn(async move {
83 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(
84 crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS,
85 );
86 loop {
87 let (socket, peer, permit) = match crate::server::accept_bounded::accept_bounded(
88 &listener,
89 &limiter,
90 b"421 4.3.2 Too many connections, try again later\r\n",
91 "LMTP",
92 Some(&ctx.status_tx),
93 )
94 .await
95 {
96 Ok(v) => v,
97 Err(_) => break,
98 };
99 let id = ConnectionId::new(ctx.state.get_next_unified_id().await);
100 let now = crate::utils::clock::Instant::now();
101 ctx.state
102 .add_connection_to_server(
103 server_id,
104 ConnectionState {
105 id,
106 remote_addr: peer,
107 local_addr: addr,
108 bytes_sent: 0,
109 bytes_received: 0,
110 packets_sent: 0,
111 packets_received: 0,
112 last_activity: now,
113 status: ConnectionStatus::Active,
114 status_changed_at: now,
115 protocol_info: ProtocolConnectionInfo::empty(),
116 },
117 )
118 .await;
119 let child = ctx.clone();
120 let cfg = cfg.clone();
121 ctx.state
122 .spawn_server_task(server_id, async move {
123 let _permit = permit;
124 if let Err(e) = session(&child, id, socket, &cfg).await {
125 Log::new(Some(&child.status_tx))
126 .warn(format!("LMTP connection {id} ended: {e}"));
127 }
128 child
129 .state
130 .update_connection_status(server_id, id, ConnectionStatus::Closed)
131 .await;
132 let _ = child.status_tx.send("__UPDATE_UI__".into());
133 })
134 .await;
135 }
136 });
137 state.register_server_task(server_id, accept).await;
138 Ok(addr)
139}
140
141async fn write<W: tokio::io::AsyncWrite + Unpin>(
142 ctx: &SpawnContext,
143 id: ConnectionId,
144 w: &mut W,
145 reply: &str,
146) -> Result<()> {
147 tokio::time::timeout(wire::IO_TIMEOUT, w.write_all(reply.as_bytes()))
148 .await
149 .context("LMTP write deadline")??;
150 ctx.state
151 .update_connection_stats(
152 ctx.server_id,
153 id,
154 None,
155 Some(reply.len() as u64),
156 None,
157 Some(1),
158 )
159 .await;
160 Ok(())
161}
162
163fn outcome(ctx: &SpawnContext, id: ConnectionId, operation: &str, decision: &str) {
164 let summary = format!("LMTP connection {id} operation={operation} decision={decision}");
165 let log = Log::new(Some(&ctx.status_tx));
166 if decision.starts_with("fail_closed_") || decision == "model_silent" {
167 log.error(summary);
168 } else {
169 log.info(summary);
170 }
171}
172
173async fn decision(
175 ctx: &SpawnContext,
176 id: ConnectionId,
177 event: Event,
178 expected: &str,
179) -> Result<Value> {
180 let result = match call_llm(
181 &ctx.llm_client,
182 &ctx.state,
183 ctx.server_id,
184 Some(id),
185 &event,
186 &actions::LmtpProtocol,
187 )
188 .await
189 {
190 Ok(result) => result,
191 Err(error) => {
192 outcome(ctx, id, event.id(), "fail_closed_llm_error");
193 return Err(error);
194 }
195 };
196 if !result.failures.is_empty() {
197 outcome(ctx, id, event.id(), "fail_closed_invalid_reply");
198 anyhow::bail!("LMTP handler supplied an invalid action");
199 }
200 let mut found = None;
201 let mut pending = result.protocol_results;
202 while let Some(result) = pending.pop() {
203 match result {
204 ActionResult::Custom { name, data } if name == expected => {
205 if found.is_some() {
206 outcome(ctx, id, event.id(), "fail_closed_invalid_reply");
207 anyhow::bail!("Multiple LMTP answers to one event");
208 }
209 found = Some(data);
210 }
211 ActionResult::Multiple(items) => pending.extend(items),
212 _ => {}
213 }
214 }
215 if found.is_none() {
216 outcome(ctx, id, event.id(), "model_silent");
217 }
218 found.context("Handler did not answer the LMTP event")
219}
220
221const TEMPORARY_FAILURE: &str = "451 4.3.0 Temporary local failure, try again later\r\n";
222
223fn delivery_replies(
225 ctx: &SpawnContext,
226 id: ConnectionId,
227 recipients: &[String],
228 answer: &Value,
229) -> String {
230 let mut listed: HashMap<String, &Value> = HashMap::new();
231 if let Some(results) = answer["results"].as_array() {
232 for result in results {
233 if let Some(recipient) = result["recipient"].as_str() {
234 listed
235 .entry(recipient.to_ascii_lowercase())
236 .or_insert(result);
237 }
238 }
239 }
240 let mut replies = String::new();
241 for recipient in recipients {
242 let reply = match listed.get(&recipient.to_ascii_lowercase()) {
243 Some(result) => {
244 let reason = result["reason"].as_str();
245 if result["delivered"] == true {
246 outcome(ctx, id, "lmtp_message", "model_answer");
247 wire::render(
248 250,
249 "2.0.0",
250 reason.unwrap_or(&format!("<{recipient}> delivered")),
251 )
252 } else if result["temporary"] == true {
253 outcome(ctx, id, "lmtp_message", "model_reject");
254 wire::render(
255 450,
256 "4.2.0",
257 reason.unwrap_or("Mailbox temporarily unavailable"),
258 )
259 } else {
260 outcome(ctx, id, "lmtp_message", "model_reject");
261 wire::render(550, "5.0.0", reason.unwrap_or("Delivery refused"))
262 }
263 }
264 None => match answer["deliver_all"].as_bool() {
265 Some(true) => {
266 outcome(ctx, id, "lmtp_message", "model_answer");
267 wire::render(250, "2.0.0", &format!("<{recipient}> delivered"))
268 }
269 Some(false) => {
270 outcome(ctx, id, "lmtp_message", "model_reject");
271 wire::render(550, "5.0.0", "Delivery refused")
272 }
273 None => {
274 outcome(ctx, id, "lmtp_message", "fail_closed_incomplete_reply");
275 TEMPORARY_FAILURE.to_string()
276 }
277 },
278 };
279 replies.push_str(&reply);
280 }
281 replies
282}
283
284enum Data {
285 Message(Vec<u8>),
286 TooBig,
287 BadLine,
288}
289
290async fn read_data<R: tokio::io::AsyncBufRead + Unpin>(
292 reader: &mut R,
293 cfg: &Config,
294) -> Result<(Data, u64)> {
295 let mut message = Vec::new();
296 let mut total: u64 = 0;
297 let mut too_big = false;
298 let mut bad_line = false;
299 loop {
300 match wire::read_line(reader, cfg.idle).await? {
301 Line::Text(line) => {
302 if line == "." {
303 break;
304 }
305 let line = line.strip_prefix('.').unwrap_or(&line);
306 total = total.saturating_add(line.len() as u64 + 2);
307 if total > cfg.max_message {
308 too_big = true;
309 message = Vec::new();
310 } else if !too_big {
311 message.extend_from_slice(line.as_bytes());
312 message.extend_from_slice(b"\r\n");
313 }
314 }
315 Line::TooLong => {
316 bad_line = true;
317 total = total.saturating_add(wire::MAX_LINE_BYTES as u64);
318 }
319 Line::Eof => anyhow::bail!("LMTP peer closed the connection during DATA"),
320 }
321 }
322 let data = if bad_line {
323 Data::BadLine
324 } else if too_big {
325 Data::TooBig
326 } else {
327 Data::Message(message)
328 };
329 Ok((data, total))
330}
331
332async fn session(
333 ctx: &SpawnContext,
334 id: ConnectionId,
335 socket: TcpStream,
336 cfg: &Config,
337) -> Result<()> {
338 let (read, mut out) = tokio::io::split(socket);
339 let mut reader = BufReader::new(read);
340 write(
341 ctx,
342 id,
343 &mut out,
344 &wire::render(220, "", &format!("{} LMTP NetGet ready", cfg.hostname)),
345 )
346 .await?;
347 let mut lhlo: Option<String> = None;
348 let mut from: Option<String> = None;
349 let mut recipients: Vec<String> = Vec::new();
350 let mut bad = 0usize;
351 loop {
352 let line = match wire::read_line(&mut reader, cfg.idle).await {
353 Ok(Line::Text(line)) => line,
354 Ok(Line::TooLong) => {
355 bad += 1;
356 write(
357 ctx,
358 id,
359 &mut out,
360 &wire::render(500, "5.5.2", "Line too long"),
361 )
362 .await?;
363 if bad >= MAX_BAD_COMMANDS {
364 write(
365 ctx,
366 id,
367 &mut out,
368 &wire::render(421, "4.7.0", "Too many errors"),
369 )
370 .await?;
371 return Ok(());
372 }
373 continue;
374 }
375 Ok(Line::Eof) => return Ok(()),
376 Err(error) => {
377 let _ = write(
378 ctx,
379 id,
380 &mut out,
381 &wire::render(421, "4.4.2", "Idle timeout, closing"),
382 )
383 .await;
384 return Err(error);
385 }
386 };
387 ctx.state
388 .update_connection_stats(
389 ctx.server_id,
390 id,
391 Some(line.len() as u64 + 2),
392 None,
393 Some(1),
394 None,
395 )
396 .await;
397 let (verb, argument) = match line.split_once(' ') {
398 Some((verb, rest)) => (verb.to_ascii_uppercase(), rest.trim().to_string()),
399 None => (line.trim().to_ascii_uppercase(), String::new()),
400 };
401 let reply = match verb.as_str() {
402 "LHLO" => {
403 if argument.is_empty() || argument.contains(' ') {
404 wire::render(501, "5.5.4", "Syntax: LHLO domain")
405 } else {
406 lhlo = Some(argument);
407 from = None;
408 recipients.clear();
409 wire::render_multi(
410 250,
411 &[
412 cfg.hostname.clone(),
413 "PIPELINING".into(),
414 "ENHANCEDSTATUSCODES".into(),
415 "8BITMIME".into(),
416 format!("SIZE {}", cfg.max_message),
417 ],
418 )
419 }
420 }
421 "HELO" | "EHLO" => wire::render(500, "5.5.1", "This is an LMTP server; use LHLO"),
422 "MAIL" => {
423 if lhlo.is_none() {
424 wire::render(503, "5.5.1", "Send LHLO first")
425 } else if from.is_some() {
426 wire::render(503, "5.5.1", "Nested MAIL command")
427 } else {
428 match wire::parse_path(&argument, "FROM:") {
429 None => wire::render(501, "5.5.4", "Syntax: MAIL FROM:<address>"),
430 Some((address, params)) => {
431 let mut refusal = None;
432 for param in ¶ms {
433 let (key, value) = param.split_once('=').unwrap_or((param, ""));
434 match key.to_ascii_uppercase().as_str() {
435 "SIZE" => match value.parse::<u64>() {
436 Ok(size) if size > cfg.max_message => {
437 refusal = Some(wire::render(
438 552,
439 "5.3.4",
440 "Message size exceeds fixed limit",
441 ))
442 }
443 Ok(_) => {}
444 Err(_) => {
445 refusal = Some(wire::render(
446 501,
447 "5.5.4",
448 "Invalid SIZE parameter",
449 ))
450 }
451 },
452 "BODY"
453 if matches!(
454 value.to_ascii_uppercase().as_str(),
455 "7BIT" | "8BITMIME"
456 ) => {}
457 _ => {
458 refusal = Some(wire::render(
459 555,
460 "5.5.4",
461 "MAIL parameter not recognized",
462 ))
463 }
464 }
465 }
466 match refusal {
467 Some(refusal) => refusal,
468 None => {
469 from = Some(address);
470 wire::render(250, "2.1.0", "Sender OK")
471 }
472 }
473 }
474 }
475 }
476 }
477 "RCPT" => match (&from, wire::parse_path(&argument, "TO:")) {
478 (None, _) => wire::render(503, "5.5.1", "Need MAIL before RCPT"),
479 (_, None) => wire::render(501, "5.5.4", "Syntax: RCPT TO:<address>"),
480 (Some(_), Some((address, _))) if address.is_empty() => {
481 wire::render(501, "5.1.3", "Empty recipient address")
482 }
483 (Some(_), Some(_)) if recipients.len() >= wire::MAX_RECIPIENTS => {
484 wire::render(452, "4.5.3", "Too many recipients")
485 }
486 (Some(sender), Some((address, _))) => {
487 let event = Event::new(
488 &actions::RECIPIENT_EVENT,
489 json!({
490 "recipient": address,
491 "mail_from": sender,
492 "lhlo": lhlo.clone().unwrap_or_default(),
493 "accepted_so_far": recipients.len(),
494 }),
495 );
496 match decision(ctx, id, event, "lmtp_recipient_reply").await {
497 Ok(answer) => {
498 let reason = answer["reason"].as_str();
499 if answer["accept"] == true {
500 outcome(ctx, id, "lmtp_recipient", "model_answer");
501 let reply = wire::render(
502 250,
503 "2.1.5",
504 reason.unwrap_or(&format!("<{address}> recipient OK")),
505 );
506 recipients.push(address);
507 reply
508 } else if answer["temporary"] == true {
509 outcome(ctx, id, "lmtp_recipient", "model_reject");
510 wire::render(
511 450,
512 "4.2.1",
513 reason.unwrap_or("Mailbox temporarily unavailable"),
514 )
515 } else {
516 outcome(ctx, id, "lmtp_recipient", "model_reject");
517 wire::render(550, "5.1.1", reason.unwrap_or("No such user here"))
518 }
519 }
520 Err(_) => TEMPORARY_FAILURE.to_string(),
521 }
522 }
523 },
524 "DATA" => {
525 if !argument.is_empty() {
526 wire::render(501, "5.5.4", "DATA takes no argument")
527 } else if from.is_none() {
528 wire::render(503, "5.5.1", "Need MAIL before DATA")
529 } else if recipients.is_empty() {
530 wire::render(503, "5.5.1", "No valid recipients")
531 } else {
532 write(
533 ctx,
534 id,
535 &mut out,
536 &wire::render(354, "", "End data with <CR><LF>.<CR><LF>"),
537 )
538 .await?;
539 let (data, size) = read_data(&mut reader, cfg).await?;
540 ctx.state
541 .update_connection_stats(ctx.server_id, id, Some(size), None, Some(1), None)
542 .await;
543 let replies = match data {
544 Data::TooBig => wire::render(552, "5.3.4", "Message too big for system")
545 .repeat(recipients.len()),
546 Data::BadLine => wire::render(500, "5.5.2", "Line too long in message")
547 .repeat(recipients.len()),
548 Data::Message(message) => {
549 let (headers, body, truncated) = wire::summarize(&message);
550 let subject = headers.get("subject").cloned().unwrap_or(Value::Null);
551 let event = Event::new(
552 &actions::MESSAGE_EVENT,
553 json!({
554 "mail_from": from.clone().unwrap_or_default(),
555 "recipients": recipients,
556 "size": message.len(),
557 "headers": headers,
558 "subject": subject,
559 "body": body,
560 "body_truncated": truncated,
561 }),
562 );
563 match decision(ctx, id, event, "lmtp_delivery").await {
564 Ok(answer) => delivery_replies(ctx, id, &recipients, &answer),
565 Err(_) => TEMPORARY_FAILURE.repeat(recipients.len()),
566 }
567 }
568 };
569 from = None;
570 recipients.clear();
571 replies
572 }
573 }
574 "RSET" => {
575 from = None;
576 recipients.clear();
577 wire::render(250, "2.0.0", "Flushed")
578 }
579 "NOOP" => wire::render(250, "2.0.0", "OK"),
580 "VRFY" => wire::render(
581 252,
582 "2.5.0",
583 "Cannot VRFY user, but will accept message and attempt delivery",
584 ),
585 "HELP" => wire::render(
586 214,
587 "2.0.0",
588 "Commands: LHLO MAIL RCPT DATA RSET NOOP VRFY HELP QUIT",
589 ),
590 "QUIT" => {
591 write(
592 ctx,
593 id,
594 &mut out,
595 &wire::render(
596 221,
597 "2.0.0",
598 &format!("{} closing connection", cfg.hostname),
599 ),
600 )
601 .await?;
602 let _ = out.shutdown().await;
603 return Ok(());
604 }
605 "STARTTLS" | "AUTH" | "BDAT" | "ETRN" | "EXPN" | "TURN" => {
606 wire::render(502, "5.5.1", "Command not implemented")
607 }
608 _ => wire::render(500, "5.5.2", "Command unrecognized"),
609 };
610 let refused = matches!(reply.get(..3), Some("500" | "501" | "502" | "503" | "555"));
611 bad = if refused { bad + 1 } else { 0 };
612 write(ctx, id, &mut out, &reply).await?;
613 if bad >= MAX_BAD_COMMANDS {
614 write(
615 ctx,
616 id,
617 &mut out,
618 &wire::render(421, "4.7.0", "Too many errors, closing"),
619 )
620 .await?;
621 let _ = out.shutdown().await;
622 return Ok(());
623 }
624 }
625}