netget/server/smtp/mod.rs
1//! SMTP server implementation
2pub mod actions;
3
4use anyhow::Result;
5use std::net::SocketAddr;
6use std::sync::Arc;
7use tokio::sync::mpsc;
8use tracing::{error, warn};
9
10use crate::console_debug;
11#[cfg(not(target_arch = "wasm32"))]
12use crate::llm::action_helper::call_llm;
13#[cfg(not(target_arch = "wasm32"))]
14use crate::llm::ollama_client::OllamaClient;
15#[cfg(not(target_arch = "wasm32"))]
16use crate::llm::ActionResult;
17#[cfg(not(target_arch = "wasm32"))]
18use crate::logging::emit::Log;
19#[cfg(not(target_arch = "wasm32"))]
20use crate::protocol::Event;
21#[cfg(not(target_arch = "wasm32"))]
22use crate::server::SmtpProtocol;
23#[cfg(not(target_arch = "wasm32"))]
24use crate::state::app_state::AppState;
25#[cfg(not(target_arch = "wasm32"))]
26use actions::SMTP_COMMAND_EVENT;
27#[cfg(not(target_arch = "wasm32"))]
28use tokio_rustls::TlsAcceptor;
29
30/// The longest single line this server will buffer, in bytes.
31///
32/// RFC 5321 §4.5.3.1 sets the minimum a server must accept at 512 octets for a command and
33/// 1000 for a `DATA` text line, and invites larger. 64 KiB is far above anything legitimate
34/// and far below what it costs us to refuse.
35///
36/// The cap is the point. `BufReader::read_line` grows its `String` until it finds a `\n`, so
37/// a peer that connects and streams bytes without ever sending one allocates until the
38/// process dies — one unauthenticated socket, no credentials, no protocol state.
39#[cfg(not(target_arch = "wasm32"))]
40pub const MAX_LINE_BYTES: usize = 64 * 1024;
41
42/// How long a session waits for the next line before it gives up on the peer.
43///
44/// RFC 5321 §4.5.3.2 sets the server-side "command" timeout at 5 minutes. Without one, a peer
45/// that connects and then says nothing holds a connection task, a socket and its slot in the
46/// connection map for as long as the process runs.
47#[cfg(not(target_arch = "wasm32"))]
48const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
49
50/// Concurrent connections this server admits before it starts refusing.
51///
52/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. This is the bound
53/// [`READ_TIMEOUT`] needs to be worth anything: RFC 5321 §4.5.3.2 makes the per-command wait
54/// five minutes, so one stranger legitimately holds a socket for five minutes and an uncapped
55/// accept loop turns that into as many five-minute holds as it can open. A mail server is the
56/// canonical target for this, which is why every real MTA ships a connection limit of its own
57/// (Postfix's `smtpd_client_connection_count_limit` defaults to 50 *per client*); 256 total is
58/// generous against a real sending host and finite against an attacker.
59#[cfg(not(target_arch = "wasm32"))]
60const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
61
62/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes, on a **plain**
63/// listener.
64///
65/// SMTP is server-speaks-first, so a refusal has a natural place: the greeting. RFC 5321 §3.1
66/// and §4.3.2 let the server open with something other than 220, and 421 — "Service not
67/// available, closing transmission channel" — is precisely this case; Postfix answers its own
68/// connection-count limit with a 421 greeting, so every MTA in the world already handles it and
69/// backs off rather than recording a permanent failure. The enhanced code matches the
70/// `421 4.3.2` this server already sends when its model backend is saturated.
71#[cfg(not(target_arch = "wasm32"))]
72const CONNECTION_CAP_REFUSAL: &[u8] = b"421 4.3.2 Too many connections, try again later\r\n";
73
74/// What a peer over [`MAX_CONNECTIONS`] gets on an **implicit-TLS (SMTPS)** listener: nothing,
75/// and then a close.
76///
77/// A peer on port 465 is mid-`ClientHello` and expects TLS records; plaintext ASCII arriving
78/// there is not a 421 it will ever read, it is a record with content type 0x34 and a nonsense
79/// version, and every TLS stack aborts the handshake with a decode error. That is a worse
80/// answer than silence — the client records a broken server rather than a busy one. The
81/// correct refusal would be a TLS `alert(internal_error)`, which cannot be formed without
82/// first completing the handshake we are declining to spend a slot on, so the honest answer is
83/// an empty write and a clean close, logged as `decision=fail_closed_connection_cap`.
84#[cfg(not(target_arch = "wasm32"))]
85const CONNECTION_CAP_REFUSAL_TLS: &[u8] = b"";
86
87/// The outcome of one bounded read.
88#[cfg(not(target_arch = "wasm32"))]
89enum LineRead {
90 Line(String),
91 /// The peer closed, or sent a partial line and then closed.
92 Eof,
93 /// `MAX_LINE_BYTES` went by without a `\n`.
94 TooLong,
95 /// `READ_TIMEOUT` went by without a byte.
96 Timeout,
97}
98
99/// Read one `\n`-terminated line, bounded in both length and time.
100///
101/// Two deliberate differences from `BufReader::read_line`:
102///
103/// * It stops at `MAX_LINE_BYTES` instead of growing without bound.
104/// * It decodes lossily instead of failing on invalid UTF-8. `read_line` returns
105/// `ErrorKind::InvalidData` for any non-UTF-8 byte and the session died on it — while the
106/// EHLO reply advertises `8BITMIME`, so the server would be promising a capability its own
107/// read path could not survive. One Latin-1 byte in a message body was
108/// enough. These bytes only ever become the model's event payload and a log line; nothing
109/// here is re-emitted on the wire, so a lossy decode loses nothing a peer can observe.
110///
111/// A partial line at EOF is reported as `Eof`, not as a line: half a command is not a command.
112#[cfg(not(target_arch = "wasm32"))]
113async fn read_line_bounded<R>(reader: &mut tokio::io::BufReader<R>) -> Result<LineRead>
114where
115 R: tokio::io::AsyncRead + Unpin,
116{
117 use tokio::io::AsyncBufReadExt;
118
119 let mut buf: Vec<u8> = Vec::new();
120 loop {
121 let chunk = match tokio::time::timeout(READ_TIMEOUT, reader.fill_buf()).await {
122 Err(_elapsed) => return Ok(LineRead::Timeout),
123 Ok(result) => result?,
124 };
125 if chunk.is_empty() {
126 return Ok(LineRead::Eof);
127 }
128 match chunk.iter().position(|&b| b == b'\n') {
129 Some(idx) => {
130 if buf.len() + idx + 1 > MAX_LINE_BYTES {
131 return Ok(LineRead::TooLong);
132 }
133 buf.extend_from_slice(&chunk[..=idx]);
134 reader.consume(idx + 1);
135 return Ok(LineRead::Line(String::from_utf8_lossy(&buf).into_owned()));
136 }
137 None => {
138 let taken = chunk.len();
139 if buf.len() + taken > MAX_LINE_BYTES {
140 return Ok(LineRead::TooLong);
141 }
142 buf.extend_from_slice(chunk);
143 reader.consume(taken);
144 }
145 }
146 }
147}
148
149/// The extensions NetGet's own EHLO reply advertises.
150///
151/// Only what this server actually does. `8BITMIME` because the reader is 8-bit clean
152/// ([`read_line_bounded`] decodes lossily rather than failing). Nothing else: there is no
153/// STARTTLS, no AUTH, no PIPELINING, and no SIZE is enforced, and advertising any of them makes
154/// a real MTA try it and fail.
155pub const EHLO_EXTENSIONS: &[&str] = &["8BITMIME"];
156
157/// NetGet's answer to `EHLO`/`HELO`, or `None` for any other line.
158///
159/// Answered here rather than by the model because the reply has exactly one correct form - the
160/// greeting's hostname and [`EHLO_EXTENSIONS`], which are facts about this implementation, not
161/// decisions. Asked, a small model answered EHLO with the 220 greeting action (so the client
162/// read a greeting as its EHLO reply and every later reply one step late), or advertised
163/// STARTTLS copied from an example, which this server cannot do.
164pub fn ehlo_reply(command: &str, hostname: &str) -> Option<Vec<u8>> {
165 let mut parts = command.trim().splitn(2, char::is_whitespace);
166 let verb = parts.next()?.to_ascii_uppercase();
167 if verb != "EHLO" && verb != "HELO" {
168 return None;
169 }
170 let client = parts.next().map(str::trim).unwrap_or("");
171 if client.is_empty() {
172 // RFC 5321 4.1.1.1: the domain argument is required.
173 return Some(format!("501 5.5.4 Syntax: {verb} hostname\r\n").into_bytes());
174 }
175 let mut reply = String::new();
176 if verb == "HELO" {
177 reply.push_str(&format!("250 {hostname} greets {client}\r\n"));
178 } else {
179 reply.push_str(&format!("250-{hostname} greets {client}\r\n"));
180 for (i, ext) in EHLO_EXTENSIONS.iter().enumerate() {
181 let sep = if i + 1 == EHLO_EXTENSIONS.len() {
182 ' '
183 } else {
184 '-'
185 };
186 reply.push_str(&format!("250{sep}{ext}\r\n"));
187 }
188 }
189 Some(reply.into_bytes())
190}
191
192/// The hostname a 220 greeting announced (`220 <host> ...`), for the EHLO reply to repeat.
193pub fn greeting_hostname(greeting: &[u8]) -> Option<String> {
194 let line = String::from_utf8_lossy(greeting);
195 let mut fields = line.split_whitespace();
196 if fields.next()? != "220" {
197 return None;
198 }
199 fields
200 .next()
201 .filter(|h| h.bytes().all(|b| b.is_ascii_graphic()))
202 .map(str::to_string)
203}
204
205/// The actions whose reply wins when a batch answers one SMTP line with several: the one the
206/// line's `answer_with` names when only one fits. See `ExecutionResult::chosen_reply`.
207pub fn smtp_preferred_actions(command: &str, in_data: bool) -> &'static [&'static str] {
208 const ACCEPT_OR_REFUSE: &[&str] = &["send_smtp_ok", "send_smtp_error"];
209 if in_data {
210 return if command == "." {
211 ACCEPT_OR_REFUSE
212 } else {
213 &[]
214 };
215 }
216 if command == "CONNECTION_ESTABLISHED" {
217 return &["send_smtp_greeting"];
218 }
219 let upper = command.to_ascii_uppercase();
220 if upper.starts_with("MAIL FROM:") || upper.starts_with("RCPT TO:") {
221 return ACCEPT_OR_REFUSE;
222 }
223 match upper.split_whitespace().next().unwrap_or("") {
224 "DATA" => &["send_smtp_start_data"],
225 "QUIT" => &["send_smtp_quit"],
226 "RSET" | "NOOP" => &["send_smtp_ok"],
227 _ => &[],
228 }
229}
230
231/// The event data for one SMTP line: the line itself, plus what NetGet can tell from it.
232///
233/// `answer_with` names the action (and reply code) this particular line takes. A small model
234/// reused one action for a whole session - the 220 greeting for EHLO and MAIL, a 250 for a
235/// recipient the instruction said to refuse - and it follows a per-request hint far better
236/// than a general description. For `MAIL FROM`/`RCPT TO` the address and its domain are
237/// split out, because an instruction like "refuse any other domain" is a decision about the
238/// domain. `in_data` is whether a 354 has gone out and the terminating `.` has not arrived:
239/// then the line is message text, whatever it looks like.
240pub fn smtp_command_event_data(command: &str, in_data: bool) -> serde_json::Value {
241 let mut data = serde_json::json!({ "command": command });
242 if in_data {
243 data["answer_with"] = if command == "." {
244 "send_smtp_ok (250) to accept the message, or send_smtp_error to refuse it".into()
245 } else {
246 "wait_for_more: a line of the message body gets no reply".into()
247 };
248 return data;
249 }
250 if command == "CONNECTION_ESTABLISHED" {
251 data["answer_with"] = "send_smtp_greeting, with the banner your instruction gives (if \
252 any) as message; close_connection to refuse the connection"
253 .into();
254 return data;
255 }
256 let upper = command.to_ascii_uppercase();
257 let address = |prefix: &str| -> Option<String> {
258 let rest = command.get(prefix.len()..)?.trim();
259 let inner = rest
260 .strip_prefix('<')
261 .and_then(|r| r.split_once('>').map(|(a, _)| a))
262 .unwrap_or_else(|| rest.split_whitespace().next().unwrap_or(""));
263 Some(inner.to_string())
264 };
265 let (answer, addr) = if upper.starts_with("MAIL FROM:") {
266 (
267 "send_smtp_ok (250) to accept this sender, or send_smtp_error with code 550 to \
268 refuse it. The recipient is decided later, at RCPT TO",
269 address("MAIL FROM:"),
270 )
271 } else if upper.starts_with("RCPT TO:") {
272 (
273 "send_smtp_ok (250) to accept mail for this recipient, or send_smtp_error with \
274 code 550 to refuse it; decide from the recipient's domain",
275 address("RCPT TO:"),
276 )
277 } else {
278 let answer = match upper.split_whitespace().next().unwrap_or("") {
279 "DATA" => "send_smtp_start_data (354); the message then arrives one line per event",
280 "QUIT" => "send_smtp_quit (221), then close_connection",
281 "RSET" | "NOOP" => "send_smtp_ok (250)",
282 _ => return data,
283 };
284 (answer, None)
285 };
286 data["answer_with"] = answer.into();
287 if let Some(addr) = addr {
288 if let Some((_, domain)) = addr.rsplit_once('@') {
289 data["domain"] = domain.to_ascii_lowercase().into();
290 }
291 data["address"] = addr.into();
292 }
293 data
294}
295
296/// SMTP server that forwards mail to LLM
297pub struct SmtpServer;
298
299#[cfg(not(target_arch = "wasm32"))]
300impl SmtpServer {
301 /// Spawn SMTP server with integrated LLM actions
302 ///
303 /// If tls_config is Some, the server will use implicit TLS (SMTPS)
304 /// If tls_config is None, the server will use plain text (SMTP)
305 pub async fn spawn_with_llm_actions(
306 listen_addr: SocketAddr,
307 llm_client: OllamaClient,
308 app_state: Arc<AppState>,
309 status_tx: mpsc::UnboundedSender<String>,
310 server_id: crate::state::ServerId,
311 tls_config: Option<Arc<rustls::ServerConfig>>,
312 ) -> Result<SocketAddr> {
313 let listener =
314 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
315 let local_addr = listener.local_addr()?;
316
317 if tls_config.is_some() {
318 Log::new(Some(&status_tx)).info(format!(
319 "SMTPS server (TLS, action-based) listening on {}",
320 local_addr
321 ));
322 } else {
323 Log::new(Some(&status_tx)).info(format!(
324 "SMTP server (plain, action-based) listening on {}",
325 local_addr
326 ));
327 }
328
329 let protocol = Arc::new(SmtpProtocol::new());
330 let tls_acceptor = tls_config.map(TlsAcceptor::from);
331
332 let task_registrar = app_state.clone();
333 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
334 // Which refusal this listener can honestly send depends on what the peer is speaking,
335 // and on this listener that is decided once, at bind time.
336 let cap_refusal: &'static [u8] = if tls_acceptor.is_some() {
337 CONNECTION_CAP_REFUSAL_TLS
338 } else {
339 CONNECTION_CAP_REFUSAL
340 };
341 let accept_handle = tokio::spawn(async move {
342 loop {
343 match crate::server::accept_bounded::accept_bounded(
344 &listener,
345 &limiter,
346 cap_refusal,
347 "SMTP",
348 Some(&status_tx),
349 )
350 .await
351 {
352 Ok((stream, remote_addr, permit)) => {
353 let connection_id = crate::server::connection::ConnectionId::new(
354 app_state.get_next_unified_id().await,
355 );
356 // Captured before a TLS handshake could consume the TcpStream.
357 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
358 console_debug!(
359 status_tx,
360 "SMTP connection {} from {}",
361 connection_id,
362 remote_addr
363 );
364
365 let llm_clone = llm_client.clone();
366 let state_clone = app_state.clone();
367 let status_clone = status_tx.clone();
368 let protocol_clone = protocol.clone();
369 let tls_acceptor_clone = tls_acceptor.clone();
370
371 // Tracked, not detached: stop_server must abort this task too.
372 let task_owner = app_state.clone();
373 task_owner
374 .spawn_server_task(server_id, async move {
375 // Released when this task ends, and this task is the whole of
376 // the connection — the TLS handshake and the session both run
377 // inside it and SMTP spawns nothing else per peer — so
378 // `MAX_CONNECTIONS` caps live connections, not accepts.
379 let _permit = permit;
380 // Optionally perform TLS handshake
381 if let Some(ref acceptor) = tls_acceptor_clone {
382 match acceptor.accept(stream).await {
383 Ok(tls_stream) => {
384 Log::new(Some(&status_clone)).debug(format!(
385 "TLS handshake completed for connection {}",
386 connection_id
387 ));
388 if let Err(e) = SmtpSession::handle_session(
389 tls_stream,
390 connection_id,
391 remote_addr,
392 local_addr_conn,
393 server_id,
394 llm_clone,
395 state_clone,
396 status_clone.clone(),
397 protocol_clone,
398 )
399 .await
400 {
401 Log::new(Some(&status_clone))
402 .error(format!("SMTP session error: {}", e));
403 }
404 }
405 Err(e) => {
406 Log::new(Some(&status_clone)).error(format!(
407 "TLS handshake failed for connection {}: {}",
408 connection_id, e
409 ));
410 }
411 }
412 } else {
413 if let Err(e) = SmtpSession::handle_session(
414 stream,
415 connection_id,
416 remote_addr,
417 local_addr_conn,
418 server_id,
419 llm_clone,
420 state_clone,
421 status_clone.clone(),
422 protocol_clone,
423 )
424 .await
425 {
426 Log::new(Some(&status_clone))
427 .error(format!("SMTP session error: {}", e));
428 }
429 };
430 })
431 .await;
432 }
433 Err(e) => {
434 error!("Failed to accept SMTP connection: {}", e);
435 break;
436 }
437 }
438 }
439 });
440
441 task_registrar
442 .register_server_task(server_id, accept_handle)
443 .await;
444
445 Ok(local_addr)
446 }
447}
448
449#[cfg(not(target_arch = "wasm32"))]
450struct SmtpSession;
451
452#[cfg(not(target_arch = "wasm32"))]
453impl SmtpSession {
454 /// Handle one SMTP session, plain or SMTPS.
455 ///
456 /// Generic over the transport: the plain and TLS paths were previously two verbatim
457 /// copies of the same greeting-then-command-loop code.
458 #[allow(clippy::too_many_arguments)]
459 async fn handle_session<S>(
460 stream: S,
461 connection_id: crate::server::connection::ConnectionId,
462 remote_addr: SocketAddr,
463 local_addr: SocketAddr,
464 server_id: crate::state::ServerId,
465 llm_client: OllamaClient,
466 app_state: Arc<AppState>,
467 status_tx: mpsc::UnboundedSender<String>,
468 protocol: Arc<SmtpProtocol>,
469 ) -> Result<()>
470 where
471 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
472 {
473 use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
474
475 let (read_half, write_half) = tokio::io::split(stream);
476 let reader = tokio::io::BufReader::new(read_half);
477 let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
478
479 // Track the connection so the dashboard lists it with live counters.
480 let now = crate::utils::clock::Instant::now();
481 app_state
482 .add_connection_to_server(
483 server_id,
484 ConnectionState {
485 id: connection_id,
486 remote_addr,
487 local_addr,
488 bytes_sent: 0,
489 bytes_received: 0,
490 packets_sent: 0,
491 packets_received: 0,
492 last_activity: now,
493 status: ConnectionStatus::Active,
494 status_changed_at: now,
495 protocol_info: ProtocolConnectionInfo::empty(),
496 },
497 )
498 .await;
499 let _ = status_tx.send("__UPDATE_UI__".to_string());
500
501 // Peer messaging: the dashboard's "message this peer" / "disconnect this peer" inject
502 // actions into THIS connection through the same executor the LLM path uses. Registered
503 // before the greeting, because a manual `*` rule can park that greeting for minutes and
504 // the operator must be able to reach the connection while it waits.
505 let peer_rx = crate::server::peer_support::register_peer_channel(
506 &app_state,
507 server_id,
508 connection_id.as_u32(),
509 )
510 .await;
511 crate::server::peer_support::spawn_peer_command_task(
512 peer_rx,
513 protocol.clone(),
514 app_state.clone(),
515 server_id,
516 connection_id.as_u32(),
517 write_half.clone(),
518 status_tx.clone(),
519 );
520
521 let result = Self::run_session(
522 reader,
523 &write_half,
524 connection_id,
525 server_id,
526 &llm_client,
527 &app_state,
528 &status_tx,
529 &protocol,
530 )
531 .await;
532
533 // Every exit path - EOF, read error, close_connection, refused greeting - lands here.
534 app_state
535 .remove_peer_handle(server_id, connection_id.as_u32())
536 .await;
537 app_state
538 .close_connection_on_server(server_id, connection_id)
539 .await;
540 let _ = status_tx.send("__UPDATE_UI__".to_string());
541 result
542 }
543
544 #[allow(clippy::too_many_arguments)]
545 async fn run_session<R, W>(
546 mut reader: tokio::io::BufReader<R>,
547 write_half: &Arc<tokio::sync::Mutex<W>>,
548 connection_id: crate::server::connection::ConnectionId,
549 server_id: crate::state::ServerId,
550 llm_client: &OllamaClient,
551 app_state: &Arc<AppState>,
552 status_tx: &mpsc::UnboundedSender<String>,
553 protocol: &Arc<SmtpProtocol>,
554 ) -> Result<()>
555 where
556 R: tokio::io::AsyncRead + Unpin,
557 W: tokio::io::AsyncWrite + Unpin,
558 {
559 use tokio::io::AsyncWriteExt;
560
561 // Send initial greeting. `None` means the model refused the connection; close
562 // without entering the command loop. Otherwise it is the hostname the greeting
563 // announced, which NetGet's own EHLO reply repeats.
564 let Some(hostname) = Self::send_greeting(
565 write_half,
566 connection_id,
567 server_id,
568 llm_client,
569 app_state,
570 status_tx,
571 protocol,
572 )
573 .await?
574 else {
575 return Ok(());
576 };
577
578 // Between a 354 and the terminating ".", every line is message text: it is never
579 // a command, whatever it looks like, and it gets no reply of its own.
580 let mut in_data = false;
581
582 loop {
583 let line = match read_line_bounded(&mut reader).await? {
584 LineRead::Line(line) => line,
585 LineRead::Eof => break,
586 LineRead::TooLong => {
587 // RFC 5321 §4.5.3.1 sets the line limits; 500 5.5.2 is the reply for
588 // breaking them. Close afterwards: we stopped reading mid-line, so the
589 // rest of that line would be parsed as fresh commands.
590 warn!(
591 "SMTP connection {} sent a line over {} bytes with no newline; closing",
592 connection_id, MAX_LINE_BYTES
593 );
594 Self::write_counted(
595 write_half,
596 b"500 5.5.2 Line too long\r\n",
597 connection_id,
598 server_id,
599 app_state,
600 status_tx,
601 )
602 .await?;
603 break;
604 }
605 LineRead::Timeout => {
606 // RFC 5321 §4.5.3.2: the server-side command timeout. 421 is the code for
607 // "service closing transmission channel", which is exactly what happens.
608 warn!(
609 "SMTP connection {} idle for {}s; closing",
610 connection_id,
611 READ_TIMEOUT.as_secs()
612 );
613 Self::write_counted(
614 write_half,
615 b"421 4.4.2 Timeout waiting for command\r\n",
616 connection_id,
617 server_id,
618 app_state,
619 status_tx,
620 )
621 .await?;
622 break;
623 }
624 };
625 let n = line.len();
626 app_state
627 .update_connection_stats(
628 server_id,
629 connection_id,
630 Some(n as u64),
631 None,
632 Some(1),
633 None,
634 )
635 .await;
636
637 let command = line.trim();
638 console_debug!(status_tx, "SMTP received: {}", command);
639
640 if !in_data {
641 if let Some(reply) = ehlo_reply(command, &hostname) {
642 Log::new(Some(status_tx)).info(format!(
643 "SMTP connection {} decision=netget_answer: {} answered by NetGet \
644 (extensions: {})",
645 connection_id,
646 crate::utils::truncate_for_log(command, 80),
647 EHLO_EXTENSIONS.join(" ")
648 ));
649 Self::write_reply(
650 write_half,
651 &reply,
652 connection_id,
653 server_id,
654 app_state,
655 status_tx,
656 )
657 .await?;
658 continue;
659 }
660 }
661
662 let event = Event::new(
663 &SMTP_COMMAND_EVENT,
664 smtp_command_event_data(command, in_data),
665 );
666 let preferred = smtp_preferred_actions(command, in_data);
667 // The terminating "." ends the message whatever the answer to it is.
668 let ends_message = in_data && command == ".";
669 if ends_message {
670 in_data = false;
671 }
672
673 match call_llm(
674 llm_client,
675 app_state,
676 server_id,
677 Some(connection_id),
678 &event,
679 protocol.as_ref(),
680 )
681 .await
682 {
683 Ok(execution_result) => {
684 let mut should_close = false;
685 let mut wrote_reply = false;
686 let mut silence_is_deliberate = false;
687 let mut dropped_replies = 0usize;
688
689 let chosen = execution_result.chosen_reply(preferred);
690 for (index, protocol_result) in
691 execution_result.protocol_results.into_iter().enumerate()
692 {
693 match protocol_result {
694 // One command, one reply. SMTP has no pipelining here, so a second
695 // reply is read by the client as the answer to its *next* command,
696 // and every reply after that is off by one for the rest of the
697 // session. Only the chosen one - the action `answer_with` names,
698 // else the first - is written.
699 ActionResult::Output(_) if Some(index) != chosen => {
700 dropped_replies += 1
701 }
702 ActionResult::Output(data) => {
703 wrote_reply = true;
704 if !ends_message && data.starts_with(b"354") {
705 in_data = true;
706 }
707 Self::write_reply(
708 write_half,
709 &data,
710 connection_id,
711 server_id,
712 app_state,
713 status_tx,
714 )
715 .await?;
716 }
717 // Do not return here: the QUIT reply is normally a 221 followed by
718 // close_connection in the same batch, and returning early would
719 // drop the 221 when the ordering came back reversed.
720 ActionResult::CloseConnection => should_close = true,
721 // `wait_for_more` is the model saying "send nothing and read the
722 // next line" - correct and required during DATA, where SMTP
723 // expects no per-line reply. It is the one answer that makes
724 // silence right, which is exactly why it has to be told apart
725 // from an empty answer below.
726 ActionResult::WaitForMore => silence_is_deliberate = true,
727 _ => {}
728 }
729 }
730
731 if dropped_replies > 0 {
732 Log::new(Some(status_tx)).warn(format!(
733 "SMTP connection {} decision=duplicate_response_dropped: {} extra \
734 repl{} to {:?} not sent; one command gets one reply",
735 connection_id,
736 dropped_replies,
737 if dropped_replies == 1 { "y" } else { "ies" },
738 crate::utils::truncate_for_log(command, 80)
739 ));
740 }
741
742 if should_close {
743 return Ok(());
744 }
745
746 // A model that answered with nothing at all used to leave the peer
747 // blocked until its own timeout - the same defect the `Err` arm's 451
748 // exists to remove, reached through the success path instead. SMTP is not
749 // one of the deliberately-silent protocols: every command owes a reply,
750 // and `wait_for_more` is already the way to decline one. So an empty
751 // answer is a failure and gets the same 451, and the log says which of
752 // the two it was.
753 if !wrote_reply && !silence_is_deliberate {
754 Log::new(Some(status_tx)).error(format!(
755 "SMTP connection {} decision=model_silent for {:?}: the model \
756 produced no reply; answering 451",
757 connection_id,
758 crate::utils::truncate_for_log(command, 200)
759 ));
760 Self::write_counted(
761 write_half,
762 b"451 4.3.0 Temporary local error, try again later\r\n",
763 connection_id,
764 server_id,
765 app_state,
766 status_tx,
767 )
768 .await?;
769 }
770 }
771 Err(e) => {
772 // Answer 451 rather than writing nothing. SMTP has a whole 4xx class
773 // meaning "temporary, retry later" (RFC 5321 §4.2.1), which is exactly
774 // what an unavailable backend is, and a client that gets one requeues the
775 // message instead of blocking until its own timeout and then bouncing it.
776 //
777 // It also fails closed: a 4xx is never mistaken for acceptance, so an
778 // outage cannot silently look like a delivered message.
779 Log::new(Some(status_tx)).error(format!(
780 "SMTP connection {} got no response for {:?}: {:#}",
781 connection_id, command, e
782 ));
783
784 // 4.3.2 is "system not accepting network messages" (RFC 3463), which is
785 // the truthful enhanced code for capacity exhaustion; 4.3.0 covers the
786 // rest.
787 let reply: &[u8] = if crate::llm::is_overload_error(&e) {
788 warn!(
789 "SMTP 451 on connection {}: LLM capacity exhausted",
790 connection_id
791 );
792 b"451 4.3.2 Backend at capacity, try again later\r\n"
793 } else {
794 b"451 4.3.0 Temporary local error, try again later\r\n"
795 };
796 let mut writer = write_half.lock().await;
797 writer.write_all(reply).await?;
798 writer.flush().await?;
799 drop(writer);
800 app_state
801 .update_connection_stats(
802 server_id,
803 connection_id,
804 None,
805 Some(reply.len() as u64),
806 None,
807 Some(1),
808 )
809 .await;
810 console_debug!(
811 status_tx,
812 "SMTP sent: {}",
813 String::from_utf8_lossy(reply).trim()
814 );
815 }
816 }
817 }
818
819 Ok(())
820 }
821
822 /// Write a fixed reply and account for it, dropping the writer guard before returning.
823 ///
824 /// The byte slice is `&'static [u8]` on purpose: everything this is used for is a protocol
825 /// constant, and a `&[u8]` parameter would be one `format!` away from putting an internal
826 /// error string on a stranger's terminal — the defect `crate::utils::wire_failure` exists
827 /// to prevent.
828 async fn write_counted<W>(
829 write_half: &Arc<tokio::sync::Mutex<W>>,
830 reply: &'static [u8],
831 connection_id: crate::server::connection::ConnectionId,
832 server_id: crate::state::ServerId,
833 app_state: &Arc<AppState>,
834 status_tx: &mpsc::UnboundedSender<String>,
835 ) -> Result<()>
836 where
837 W: tokio::io::AsyncWrite + Unpin,
838 {
839 use tokio::io::AsyncWriteExt;
840
841 {
842 let mut writer = write_half.lock().await;
843 writer.write_all(reply).await?;
844 writer.flush().await?;
845 }
846 app_state
847 .update_connection_stats(
848 server_id,
849 connection_id,
850 None,
851 Some(reply.len() as u64),
852 None,
853 Some(1),
854 )
855 .await;
856 console_debug!(
857 status_tx,
858 "SMTP sent: {}",
859 String::from_utf8_lossy(reply).trim()
860 );
861 Ok(())
862 }
863
864 /// Write one reply built at runtime (a model's answer, NetGet's EHLO reply) and account
865 /// for it.
866 async fn write_reply<W>(
867 write_half: &Arc<tokio::sync::Mutex<W>>,
868 reply: &[u8],
869 connection_id: crate::server::connection::ConnectionId,
870 server_id: crate::state::ServerId,
871 app_state: &Arc<AppState>,
872 status_tx: &mpsc::UnboundedSender<String>,
873 ) -> Result<()>
874 where
875 W: tokio::io::AsyncWrite + Unpin,
876 {
877 use tokio::io::AsyncWriteExt;
878
879 {
880 let mut writer = write_half.lock().await;
881 writer.write_all(reply).await?;
882 writer.flush().await?;
883 }
884 app_state
885 .update_connection_stats(
886 server_id,
887 connection_id,
888 None,
889 Some(reply.len() as u64),
890 None,
891 Some(1),
892 )
893 .await;
894 console_debug!(
895 status_tx,
896 "SMTP sent: {}",
897 String::from_utf8_lossy(reply).trim()
898 );
899 Ok(())
900 }
901
902 /// Send the greeting.
903 ///
904 /// Returns `Ok(None)` when the model answered the greeting event with `close_connection`
905 /// — a deliberate refusal, which is a different thing from a backend failure and must not
906 /// be reported as one. `Err` is reserved for the backend actually failing, and carries the
907 /// 421 that has already been written. Otherwise `Ok(Some(hostname))`: the host the banner
908 /// announced, or `localhost` when it named none.
909 async fn send_greeting<W>(
910 write_half: &Arc<tokio::sync::Mutex<W>>,
911 connection_id: crate::server::connection::ConnectionId,
912 server_id: crate::state::ServerId,
913 llm_client: &OllamaClient,
914 app_state: &Arc<AppState>,
915 status_tx: &mpsc::UnboundedSender<String>,
916 protocol: &Arc<SmtpProtocol>,
917 ) -> Result<Option<String>>
918 where
919 W: tokio::io::AsyncWrite + Unpin,
920 {
921 use tokio::io::AsyncWriteExt;
922
923 let greeting_event = Event::new(
924 &SMTP_COMMAND_EVENT,
925 smtp_command_event_data("CONNECTION_ESTABLISHED", false),
926 );
927 let mut hostname = None;
928
929 match call_llm(
930 llm_client,
931 app_state,
932 server_id,
933 Some(connection_id),
934 &greeting_event,
935 protocol.as_ref(),
936 )
937 .await
938 {
939 Ok(execution_result) => {
940 let mut refused = false;
941 let mut dropped_replies = 0usize;
942 let chosen = execution_result
943 .chosen_reply(smtp_preferred_actions("CONNECTION_ESTABLISHED", false));
944 for (index, protocol_result) in
945 execution_result.protocol_results.into_iter().enumerate()
946 {
947 match protocol_result {
948 // One banner. A second 220 would be read as the reply to the client's
949 // first command, leaving every reply after it one step late.
950 ActionResult::Output(_) if Some(index) != chosen => dropped_replies += 1,
951 ActionResult::Output(data) => {
952 hostname = greeting_hostname(&data);
953 Self::write_reply(
954 write_half,
955 &data,
956 connection_id,
957 server_id,
958 app_state,
959 status_tx,
960 )
961 .await?;
962 }
963 // `close_connection` is advertised on `smtp_command`, and the greeting
964 // *is* an `smtp_command` event, so refusing the connection is an answer
965 // the model is explicitly offered. This arm used to be an
966 // `if let ActionResult::Output(..)`, which dropped the refusal on the
967 // floor and carried on into the command loop on a connection the model
968 // had declined - a denial that did not deny.
969 ActionResult::CloseConnection => refused = true,
970 _ => {}
971 }
972 }
973 if dropped_replies > 0 {
974 Log::new(Some(status_tx)).warn(format!(
975 "SMTP connection {} decision=duplicate_response_dropped: {} extra \
976 greeting repl{} not sent",
977 connection_id,
978 dropped_replies,
979 if dropped_replies == 1 { "y" } else { "ies" }
980 ));
981 }
982 if refused {
983 Log::new(Some(status_tx)).info(format!(
984 "SMTP connection {} decision=model_reject: the model refused the \
985 connection at the greeting",
986 connection_id
987 ));
988 return Ok(None);
989 }
990 }
991 Err(e) => {
992 // No banner means no session. RFC 5321 §3.1 gives a server exactly this way
993 // to decline one: a 421 greeting, after which the connection closes. Writing
994 // nothing (the previous behaviour) left the peer waiting for a 220 until its
995 // own timeout, with no way to tell an overloaded server from a black hole.
996 Log::new(Some(status_tx)).error(format!(
997 "SMTP greeting for connection {} failed: {:#}",
998 connection_id, e
999 ));
1000
1001 let reply: &[u8] = if crate::llm::is_overload_error(&e) {
1002 warn!(
1003 "SMTP 421 on connection {}: LLM capacity exhausted",
1004 connection_id
1005 );
1006 b"421 4.3.2 Service not available, backend at capacity\r\n"
1007 } else {
1008 b"421 4.3.0 Service not available, closing transmission channel\r\n"
1009 };
1010 let mut writer = write_half.lock().await;
1011 writer.write_all(reply).await?;
1012 writer.flush().await?;
1013 drop(writer);
1014 app_state
1015 .update_connection_stats(
1016 server_id,
1017 connection_id,
1018 None,
1019 Some(reply.len() as u64),
1020 None,
1021 Some(1),
1022 )
1023 .await;
1024 let _ = status_tx.send(format!(
1025 "→ SMTP {} to connection {}",
1026 String::from_utf8_lossy(reply).trim(),
1027 connection_id
1028 ));
1029
1030 // Propagate so handle_session stops before the command loop: after a 421 the
1031 // only thing the server may do is close.
1032 anyhow::bail!("SMTP greeting unavailable: {e:#}");
1033 }
1034 }
1035
1036 Ok(Some(hostname.unwrap_or_else(|| "localhost".to_string())))
1037 }
1038}
1039
1040#[cfg(target_arch = "wasm32")]
1041impl SmtpServer {
1042 pub async fn spawn_with_llm_actions(
1043 _listen_addr: SocketAddr,
1044 _llm_client: OllamaClient,
1045 _app_state: Arc<AppState>,
1046 _status_tx: mpsc::UnboundedSender<String>,
1047 _server_id: crate::state::ServerId,
1048 _tls_config: Option<Arc<rustls::ServerConfig>>,
1049 ) -> Result<SocketAddr> {
1050 anyhow::bail!("SMTP feature not enabled")
1051 }
1052}