Skip to main content

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}