Skip to main content

netget/client/smtp/
mod.rs

1//! SMTP client implementation using lettre library
2pub mod actions;
3
4pub use actions::SmtpClientProtocol;
5
6use anyhow::{Context, Result};
7use lettre::transport::smtp::authentication::Credentials;
8use lettre::transport::smtp::client::{Tls, TlsParameters};
9use lettre::{SmtpTransport, Transport};
10use std::net::SocketAddr;
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::mpsc;
14use tracing::{error, info, trace};
15
16use crate::client::llm_budget::call_llm_for_client;
17use crate::client::smtp::actions::SMTP_CLIENT_CONNECTED_EVENT;
18use crate::llm::actions::client_trait::{Client, ClientActionResult};
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::protocol::{Event, StartupParams};
22use crate::state::app_state::AppState;
23use crate::state::{ClientId, ClientStatus};
24
25/// How often the idle task checks whether the client row is still there. The same task
26/// drains injected commands, so this is a floor on shutdown latency, not on responsiveness.
27const LIVENESS_INTERVAL: Duration = Duration::from_secs(5);
28
29/// The session defaults taken from the declared startup parameters.
30///
31/// `username`, `password` and `use_tls` are declared in `get_startup_parameters()` and were
32/// read by nothing at connect: `deliver` took all three off the *action* instead, so what the
33/// caller passed when opening the client was discarded. Nothing failed loudly — a client
34/// opened against a plaintext relay simply demanded STARTTLS on every message (the action's
35/// own default is `true`) and every delivery was refused, and credentials supplied once at
36/// connect had to be repeated by the model on every single `send_email` or no AUTH was sent.
37///
38/// They arrive on `ConnectContext::startup_params`. `ClientInstance::protocol_data` is not a
39/// second source here: `cli/client_startup.rs` leaves it `Value::Null`, and the only keys this
40/// client writes there are the ones it derives itself (`smtp_server`, `smtp_port`).
41///
42/// A value on the action still wins — the model may legitimately authenticate as someone else
43/// for one message — so these are defaults, not overrides.
44#[derive(Clone, Debug, Default)]
45struct SmtpSettings {
46    username: Option<String>,
47    password: Option<String>,
48    /// `None` means the caller said nothing; `deliver` then defaults to STARTTLS.
49    use_tls: Option<bool>,
50}
51
52impl SmtpSettings {
53    /// Read the three declared parameters, propagating a wrong-typed value rather than
54    /// unwrapping it: over MCP an `unwrap` here kills the request task and hangs the caller.
55    fn from_startup_params(params: Option<&StartupParams>) -> Result<Self> {
56        let Some(params) = params else {
57            return Ok(Self::default());
58        };
59        Ok(Self {
60            username: params.get_optional_string("username")?,
61            password: params.get_optional_string("password")?,
62            use_tls: params.get_optional_bool("use_tls")?,
63        })
64    }
65}
66
67/// What one executed SMTP action did.
68///
69/// SMTP has no persistent socket here: `lettre` opens a connection per message. So an
70/// "applied" action is a completed delivery, not bytes on a stream this client owns.
71enum SmtpApplied {
72    /// A message was accepted by the server.
73    Delivered {
74        to: Vec<String>,
75        subject: String,
76        endpoint: String,
77    },
78    /// The action asked to end the session.
79    Disconnect,
80    /// The action ran but sent nothing.
81    Nothing(String),
82}
83
84/// SMTP client that sends emails via SMTP servers
85pub struct SmtpClient;
86
87impl SmtpClient {
88    /// Connect to an SMTP server with integrated LLM actions
89    pub async fn connect_with_llm_actions(
90        remote_addr: String,
91        llm_client: OllamaClient,
92        app_state: Arc<AppState>,
93        status_tx: mpsc::UnboundedSender<String>,
94        client_id: ClientId,
95        startup_params: Option<StartupParams>,
96    ) -> Result<SocketAddr> {
97        info!(
98            "SMTP client {} initializing connection to {}",
99            client_id, remote_addr
100        );
101
102        // Read before anything is registered, so a wrong-typed parameter becomes an `Error`
103        // status on a client that never half-started.
104        let settings = Arc::new(SmtpSettings::from_startup_params(startup_params.as_ref())?);
105
106        // Parse server address (format: hostname:port or just hostname). The port is kept:
107        // without it every message went to whatever `SmtpTransport::relay` defaults to, so a
108        // client pointed at `host:2525` silently talked to a different port.
109        let (smtp_server, smtp_port) = match remote_addr.rsplit_once(':') {
110            Some((host, port)) => (host.to_string(), port.parse::<u16>().ok()),
111            None => (remote_addr.clone(), None),
112        };
113
114        // Store connection info in protocol data
115        app_state
116            .with_client_mut(client_id, |client| {
117                client
118                    .set_protocol_field("smtp_server".to_string(), serde_json::json!(smtp_server));
119                client.set_protocol_field("smtp_port".to_string(), serde_json::json!(smtp_port));
120                client
121                    .set_protocol_field("remote_addr".to_string(), serde_json::json!(remote_addr));
122            })
123            .await;
124
125        // Update status to connected
126        app_state
127            .update_client_status(client_id, ClientStatus::Connected)
128            .await;
129        let _ = status_tx.send(format!(
130            "[CLIENT] SMTP client {} ready for {}",
131            client_id, remote_addr
132        ));
133        let _ = status_tx.send("__UPDATE_UI__".to_string());
134
135        // The dashboard's `[ send ]` channel, registered — and drained — BEFORE the
136        // connected-event LLM call below. A dashboard-created client defaults to a
137        // `*` -> manual rule, so that call can park for minutes waiting for a human; the
138        // operator has to be able to reach the client while it waits, which means the
139        // draining task must already exist, not just the handle.
140        let command_rx =
141            crate::client::command_support::register_command_channel(&app_state, client_id).await;
142        let command_task = tokio::spawn(Self::idle_and_command_loop(
143            command_rx,
144            client_id,
145            llm_client.clone(),
146            app_state.clone(),
147            status_tx.clone(),
148            settings.clone(),
149        ));
150        app_state
151            .register_client_task(client_id, command_task)
152            .await;
153
154        // Call LLM with connected event
155        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
156            let protocol = SmtpClientProtocol::new();
157            let event = Event::new(
158                &SMTP_CLIENT_CONNECTED_EVENT,
159                serde_json::json!({
160                    "smtp_server": smtp_server,
161                }),
162            );
163
164            let memory = app_state
165                .get_memory_for_client(client_id)
166                .await
167                .unwrap_or_default();
168
169            match call_llm_for_client(
170                &llm_client,
171                &app_state,
172                client_id.to_string(),
173                &instruction,
174                &memory,
175                Some(&event),
176                &protocol,
177                &status_tx,
178            )
179            .await
180            {
181                Ok(ClientLlmResult {
182                    actions,
183                    memory_updates,
184                }) => {
185                    // Update memory
186                    if let Some(mem) = memory_updates {
187                        app_state.set_memory_for_client(client_id, mem).await;
188                    }
189
190                    // Actions used to be discarded here (`actions: _`), which made
191                    // `send_email` unreachable from every path: the model could ask for a
192                    // message and nothing was ever sent. They go through the same
193                    // `apply_action` an injected command uses.
194                    for action in actions {
195                        match Self::apply_action(client_id, &app_state, action, &settings).await {
196                            Ok(applied) => {
197                                Self::follow_up(
198                                    applied,
199                                    client_id,
200                                    &llm_client,
201                                    &app_state,
202                                    &status_tx,
203                                    &settings,
204                                )
205                                .await
206                            }
207                            Err(e) => {
208                                error!(
209                                    "SMTP client {} could not execute action after connect: {}",
210                                    client_id, e
211                                );
212                            }
213                        }
214                    }
215                }
216                Err(e) => {
217                    error!("LLM error for SMTP client {}: {}", client_id, e);
218                }
219            }
220        }
221
222        // Return a dummy local address (SMTP is request-based, not persistent)
223        Ok("0.0.0.0:0".parse().unwrap())
224    }
225
226    /// Drain injected commands (the dashboard's `[ send ]`) and, on the same task, notice
227    /// when the client row goes away.
228    ///
229    /// This replaced a bare 5-second `sleep` loop that did nothing but the liveness check.
230    /// Both futures here are cancellation-safe (`mpsc::Receiver::recv` and `Interval::tick`),
231    /// so a `select!` is correct and no separate task is needed.
232    ///
233    /// Outcome semantics: `lettre` opens its own connection per message and reports no byte
234    /// count, so a delivered message is `Executed` naming the recipients and the endpoint,
235    /// never `Sent`. A message the server refused is an `Err`.
236    async fn idle_and_command_loop(
237        mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
238        client_id: ClientId,
239        llm_client: OllamaClient,
240        app_state: Arc<AppState>,
241        status_tx: mpsc::UnboundedSender<String>,
242        settings: Arc<SmtpSettings>,
243    ) {
244        use crate::llm::actions::protocol_trait::Protocol;
245        use crate::state::client_handles::ClientSendOutcome;
246        use crate::state::AccessLogOwner;
247
248        let protocol = SmtpClientProtocol::new();
249        let mut liveness = tokio::time::interval(LIVENESS_INTERVAL);
250        liveness.tick().await; // the first tick completes immediately
251
252        loop {
253            let command = tokio::select! {
254                _ = liveness.tick() => {
255                    if app_state.get_client(client_id).await.is_none() {
256                        info!("SMTP client {} stopped", client_id);
257                        break;
258                    }
259                    continue;
260                }
261                command = command_rx.recv() => match command {
262                    Some(command) => command,
263                    None => break,
264                },
265            };
266
267            let action = command.action.clone();
268            let mut applied_for_follow_up = None;
269            let mut disconnect = false;
270
271            let outcome: Result<ClientSendOutcome> =
272                match Self::apply_action(client_id, &app_state, action.clone(), &settings).await {
273                    Err(e) => Ok(ClientSendOutcome::Rejected {
274                        error: e.to_string(),
275                    }),
276                    Ok(SmtpApplied::Disconnect) => {
277                        disconnect = true;
278                        Ok(ClientSendOutcome::Disconnected)
279                    }
280                    Ok(SmtpApplied::Nothing(detail)) => Ok(ClientSendOutcome::Executed { detail }),
281                    Ok(SmtpApplied::Delivered {
282                        to,
283                        subject,
284                        endpoint,
285                    }) => {
286                        let detail = format!(
287                            "send_email '{}': accepted by {} for {} recipient(s)",
288                            subject,
289                            endpoint,
290                            to.len()
291                        );
292                        applied_for_follow_up = Some(SmtpApplied::Delivered {
293                            to,
294                            subject,
295                            endpoint,
296                        });
297                        Ok(ClientSendOutcome::Executed { detail })
298                    }
299                };
300
301            let outcome_json = match &outcome {
302                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
303                Err(e) => serde_json::json!({"error": e.to_string()}),
304            };
305            app_state
306                .record_access_log(
307                    AccessLogOwner::Client(client_id.as_u32()),
308                    protocol.protocol_name(),
309                    None,
310                    "injected_action",
311                    action,
312                    vec![outcome_json],
313                )
314                .await;
315
316            if let Err(e) = &outcome {
317                error!("SMTP client {} injected action failed: {}", client_id, e);
318                let _ = status_tx.send(format!(
319                    "[WARN] Client {} injected action failed: {}",
320                    client_id, e
321                ));
322            }
323            let _ = status_tx.send("__UPDATE_UI__".to_string());
324            crate::client::command_support::reply(command, outcome);
325
326            if disconnect {
327                app_state
328                    .update_client_status(client_id, ClientStatus::Disconnected)
329                    .await;
330                break;
331            }
332
333            // The `smtp_email_sent` event goes to the model in its own task: a handler parked
334            // for a human must not block the next injected command.
335            if let Some(applied) = applied_for_follow_up {
336                let llm_client = llm_client.clone();
337                let state = app_state.clone();
338                let tx = status_tx.clone();
339                let settings = settings.clone();
340                let handle = tokio::spawn(async move {
341                    Self::follow_up(applied, client_id, &llm_client, &state, &tx, &settings).await;
342                });
343                app_state.register_client_task(client_id, handle).await;
344            }
345        }
346
347        // Every exit lands here: drop the handle so the rail stops offering `[ send ]` on a
348        // client nothing is draining any more.
349        app_state.remove_client_handle(client_id).await;
350        let _ = status_tx.send("__UPDATE_UI__".to_string());
351    }
352
353    /// Execute one action against the configured SMTP server.
354    ///
355    /// The single place `send_email` is turned into a delivery, shared by the connected-event
356    /// path and by injected dashboard commands. It makes no LLM call of its own — that is
357    /// [`Self::follow_up`]'s job — so the command loop can reply before the model reacts.
358    async fn apply_action(
359        client_id: ClientId,
360        app_state: &Arc<AppState>,
361        action: serde_json::Value,
362        settings: &SmtpSettings,
363    ) -> Result<SmtpApplied> {
364        let protocol = SmtpClientProtocol::new();
365        match protocol.execute_action(action)? {
366            ClientActionResult::Custom { name, data } if name == "smtp_send_email" => {
367                let to: Vec<String> = data
368                    .get("to")
369                    .and_then(|v| v.as_array())
370                    .map(|a| {
371                        a.iter()
372                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
373                            .collect()
374                    })
375                    .unwrap_or_default();
376                let subject = data
377                    .get("subject")
378                    .and_then(|v| v.as_str())
379                    .unwrap_or_default()
380                    .to_string();
381
382                let endpoint = Self::deliver(client_id, app_state, &data, settings).await?;
383                Ok(SmtpApplied::Delivered {
384                    to,
385                    subject,
386                    endpoint,
387                })
388            }
389            ClientActionResult::Disconnect => Ok(SmtpApplied::Disconnect),
390            ClientActionResult::WaitForMore => {
391                Ok(SmtpApplied::Nothing("wait_for_more".to_string()))
392            }
393            other => Ok(SmtpApplied::Nothing(format!(
394                "unsupported action result {other:?}"
395            ))),
396        }
397    }
398
399    /// Build the message and hand it to `lettre`. Returns the `host:port` it was accepted by.
400    async fn deliver(
401        client_id: ClientId,
402        app_state: &Arc<AppState>,
403        data: &serde_json::Value,
404        settings: &SmtpSettings,
405    ) -> Result<String> {
406        use lettre::message::Message;
407
408        let (smtp_server, smtp_port) = app_state
409            .with_client_mut(client_id, |client| {
410                (
411                    client
412                        .get_protocol_field("smtp_server")
413                        .and_then(|v| v.as_str())
414                        .map(|s| s.to_string()),
415                    client
416                        .get_protocol_field("smtp_port")
417                        .and_then(|v| v.as_u64())
418                        .and_then(|p| u16::try_from(p).ok()),
419                )
420            })
421            .await
422            .context("SMTP client is no longer registered")?;
423        let smtp_server = smtp_server.context("No SMTP server found")?;
424
425        let from = data
426            .get("from")
427            .and_then(|v| v.as_str())
428            .context("Missing 'from' field")?;
429        let to: Vec<String> = data
430            .get("to")
431            .and_then(|v| v.as_array())
432            .context("Missing 'to' field")?
433            .iter()
434            .filter_map(|v| v.as_str().map(|s| s.to_string()))
435            .collect();
436        let subject = data
437            .get("subject")
438            .and_then(|v| v.as_str())
439            .unwrap_or_default();
440        let body = data
441            .get("body")
442            .and_then(|v| v.as_str())
443            .unwrap_or_default()
444            .to_string();
445        // The action wins where it says something; the startup parameters are the session
446        // default; STARTTLS is the default of last resort. `execute_action` deliberately omits
447        // a field the action did not carry, so "absent" here really means the model said
448        // nothing rather than "the executor filled in `true` for it".
449        let username = data
450            .get("username")
451            .and_then(|v| v.as_str())
452            .map(|s| s.to_string())
453            .or_else(|| settings.username.clone());
454        let password = data
455            .get("password")
456            .and_then(|v| v.as_str())
457            .map(|s| s.to_string())
458            .or_else(|| settings.password.clone());
459        let use_tls = data
460            .get("use_tls")
461            .and_then(|v| v.as_bool())
462            .or(settings.use_tls)
463            .unwrap_or(true);
464
465        info!("SMTP client {} sending email to {:?}", client_id, to);
466
467        let mut message_builder = Message::builder()
468            .from(from.parse().context("Invalid 'from' address")?)
469            .subject(subject);
470        for recipient in &to {
471            message_builder =
472                message_builder.to(recipient.parse().context("Invalid 'to' address")?);
473        }
474        let email = message_builder
475            .body(body)
476            .context("Failed to build email message")?;
477
478        let mut transport_builder =
479            SmtpTransport::relay(&smtp_server).context("Failed to create SMTP transport")?;
480        if let Some(port) = smtp_port {
481            transport_builder = transport_builder.port(port);
482        }
483
484        if let (Some(user), Some(pass)) = (username, password) {
485            transport_builder = transport_builder.credentials(Credentials::new(user, pass));
486        }
487
488        if use_tls {
489            let tls_parameters = TlsParameters::builder(smtp_server.clone())
490                .dangerous_accept_invalid_certs(false)
491                .build()
492                .context("Failed to build TLS parameters")?;
493            transport_builder = transport_builder.tls(Tls::Required(tls_parameters));
494        } else {
495            transport_builder = transport_builder.tls(Tls::None);
496        }
497
498        let mailer = transport_builder.build();
499
500        // `lettre`'s SmtpTransport is blocking.
501        let response = tokio::task::spawn_blocking(move || mailer.send(&email))
502            .await
503            .context("Task join error")?
504            .context("SMTP server refused the message")?;
505
506        info!(
507            "SMTP client {} sent email successfully: {:?}",
508            client_id, response
509        );
510
511        Ok(match smtp_port {
512            Some(port) => format!("{smtp_server}:{port}"),
513            None => smtp_server,
514        })
515    }
516
517    /// Raise `smtp_email_sent` for a delivered message and execute whatever the handler
518    /// answers.
519    async fn follow_up(
520        applied: SmtpApplied,
521        client_id: ClientId,
522        llm_client: &OllamaClient,
523        app_state: &Arc<AppState>,
524        status_tx: &mpsc::UnboundedSender<String>,
525        settings: &SmtpSettings,
526    ) {
527        let SmtpApplied::Delivered { to, subject, .. } = applied else {
528            match applied {
529                SmtpApplied::Disconnect => {
530                    info!("SMTP client {} disconnecting", client_id);
531                    app_state.remove_client_handle(client_id).await;
532                    app_state
533                        .update_client_status(client_id, ClientStatus::Disconnected)
534                        .await;
535                    let _ = status_tx.send("__UPDATE_UI__".to_string());
536                }
537                SmtpApplied::Nothing(detail) => {
538                    trace!("SMTP client {} action sent nothing: {}", client_id, detail);
539                }
540                SmtpApplied::Delivered { .. } => unreachable!(),
541            }
542            return;
543        };
544
545        let _ = status_tx.send("[CLIENT] SMTP email sent successfully".to_string());
546
547        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
548            return;
549        };
550        let protocol = SmtpClientProtocol::new();
551        let event = Event::new(
552            &crate::client::smtp::actions::SMTP_CLIENT_EMAIL_SENT_EVENT,
553            serde_json::json!({
554                "to": to,
555                "subject": subject,
556                "success": true,
557            }),
558        );
559
560        let memory = app_state
561            .get_memory_for_client(client_id)
562            .await
563            .unwrap_or_default();
564
565        match call_llm_for_client(
566            llm_client,
567            app_state,
568            client_id.to_string(),
569            &instruction,
570            &memory,
571            Some(&event),
572            &protocol,
573            status_tx,
574        )
575        .await
576        {
577            Ok(ClientLlmResult {
578                actions,
579                memory_updates,
580            }) => {
581                if let Some(mem) = memory_updates {
582                    app_state.set_memory_for_client(client_id, mem).await;
583                }
584                for action in actions {
585                    match Self::apply_action(client_id, app_state, action, settings).await {
586                        Ok(applied) => {
587                            Box::pin(Self::follow_up(
588                                applied, client_id, llm_client, app_state, status_tx, settings,
589                            ))
590                            .await
591                        }
592                        Err(e) => {
593                            error!("SMTP client {} follow-up action failed: {}", client_id, e)
594                        }
595                    }
596                }
597            }
598            Err(e) => {
599                error!("LLM error for SMTP client {}: {}", client_id, e);
600            }
601        }
602    }
603}