Skip to main content

netget/client/smtp/
actions.rs

1//! SMTP client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// SMTP client connected event
15pub static SMTP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "smtp_connected",
18        "SMTP client connected to server and ready to send emails",
19        json!({
20            "type": "send_email",
21            "from": "sender@example.com",
22            "to": ["recipient@example.com"],
23            "subject": "Follow-up Email",
24            "body": "This is a follow-up email."
25        }),
26    )
27    .with_parameters(vec![Parameter {
28        name: "smtp_server".to_string(),
29        type_hint: "string".to_string(),
30        description: "SMTP server hostname".to_string(),
31        required: true,
32    }])
33});
34
35/// SMTP email sent event
36pub static SMTP_CLIENT_EMAIL_SENT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
37    EventType::new(
38        "smtp_email_sent",
39        "Email successfully sent via SMTP",
40        json!({"type": "placeholder", "event_id": "smtp_email_sent"}),
41    )
42    .with_parameters(vec![
43        Parameter {
44            name: "to".to_string(),
45            type_hint: "array".to_string(),
46            description: "Recipient email addresses".to_string(),
47            required: true,
48        },
49        Parameter {
50            name: "subject".to_string(),
51            type_hint: "string".to_string(),
52            description: "Email subject".to_string(),
53            required: true,
54        },
55        Parameter {
56            name: "success".to_string(),
57            type_hint: "boolean".to_string(),
58            description: "Whether email was sent successfully".to_string(),
59            required: true,
60        },
61    ])
62});
63
64/// SMTP client protocol action handler
65#[derive(Default)]
66pub struct SmtpClientProtocol;
67
68impl SmtpClientProtocol {
69    pub fn new() -> Self {
70        Self
71    }
72}
73
74// Implement Protocol trait (common functionality)
75impl Protocol for SmtpClientProtocol {
76    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
77        vec![
78            ParameterDefinition {
79                name: "username".to_string(),
80                description: "SMTP authentication username (optional)".to_string(),
81                type_hint: "string".to_string(),
82                required: false,
83                example: json!("user@example.com"),
84                default: None,
85            },
86            ParameterDefinition {
87                name: "password".to_string(),
88                description: "SMTP authentication password (optional)".to_string(),
89                type_hint: "string".to_string(),
90                required: false,
91                example: json!("secret123"),
92                default: None,
93            },
94            ParameterDefinition {
95                name: "use_tls".to_string(),
96                description: "Use STARTTLS for secure connection (default: true)".to_string(),
97                type_hint: "boolean".to_string(),
98                required: false,
99                example: json!(true),
100                default: None,
101            },
102        ]
103    }
104    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
105        vec![
106            ActionDefinition {
107                name: "send_email".to_string(),
108                description: "Send an email via SMTP".to_string(),
109                parameters: vec![
110                    Parameter {
111                        name: "from".to_string(),
112                        type_hint: "string".to_string(),
113                        description: "Sender email address".to_string(),
114                        required: true,
115                    },
116                    Parameter {
117                        name: "to".to_string(),
118                        type_hint: "array".to_string(),
119                        description: "Recipient email addresses".to_string(),
120                        required: true,
121                    },
122                    Parameter {
123                        name: "subject".to_string(),
124                        type_hint: "string".to_string(),
125                        description: "Email subject".to_string(),
126                        required: true,
127                    },
128                    Parameter {
129                        name: "body".to_string(),
130                        type_hint: "string".to_string(),
131                        description: "Email body (plain text)".to_string(),
132                        required: true,
133                    },
134                    Parameter {
135                        name: "username".to_string(),
136                        type_hint: "string".to_string(),
137                        description: "SMTP authentication username (optional)".to_string(),
138                        required: false,
139                    },
140                    Parameter {
141                        name: "password".to_string(),
142                        type_hint: "string".to_string(),
143                        description: "SMTP authentication password (optional)".to_string(),
144                        required: false,
145                    },
146                    Parameter {
147                        name: "use_tls".to_string(),
148                        type_hint: "boolean".to_string(),
149                        description: "Use STARTTLS (default: true)".to_string(),
150                        required: false,
151                    },
152                ],
153                example: json!({
154                    "type": "send_email",
155                    "from": "sender@example.com",
156                    "to": ["recipient@example.com"],
157                    "subject": "Test Email",
158                    "body": "Hello, this is a test email from NetGet SMTP client.",
159                    "use_tls": true
160                }),
161                log_template: None,
162            },
163            ActionDefinition {
164                name: "disconnect".to_string(),
165                description: "Disconnect from the SMTP server".to_string(),
166                parameters: vec![],
167                example: json!({
168                    "type": "disconnect"
169                }),
170                log_template: None,
171            },
172        ]
173    }
174    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
175        vec![ActionDefinition {
176            name: "send_email".to_string(),
177            description: "Send another email in response to previous operation".to_string(),
178            parameters: vec![
179                Parameter {
180                    name: "from".to_string(),
181                    type_hint: "string".to_string(),
182                    description: "Sender email address".to_string(),
183                    required: true,
184                },
185                Parameter {
186                    name: "to".to_string(),
187                    type_hint: "array".to_string(),
188                    description: "Recipient email addresses".to_string(),
189                    required: true,
190                },
191                Parameter {
192                    name: "subject".to_string(),
193                    type_hint: "string".to_string(),
194                    description: "Email subject".to_string(),
195                    required: true,
196                },
197                Parameter {
198                    name: "body".to_string(),
199                    type_hint: "string".to_string(),
200                    description: "Email body".to_string(),
201                    required: true,
202                },
203            ],
204            example: json!({
205                "type": "send_email",
206                "from": "sender@example.com",
207                "to": ["recipient@example.com"],
208                "subject": "Follow-up Email",
209                "body": "This is a follow-up email."
210            }),
211            log_template: None,
212        }]
213    }
214    fn protocol_name(&self) -> &'static str {
215        "SMTP"
216    }
217    fn get_event_types(&self) -> Vec<EventType> {
218        vec![
219            EventType::new(
220                "smtp_connected",
221                "Triggered when SMTP client connects to server",
222                json!({"type": "placeholder", "event_id": "smtp_connected"}),
223            ),
224            EventType::new(
225                "smtp_email_sent",
226                "Triggered when email is successfully sent",
227                json!({"type": "placeholder", "event_id": "smtp_email_sent"}),
228            ),
229        ]
230    }
231    fn stack_name(&self) -> &'static str {
232        "ETH>IP>TCP>SMTP"
233    }
234    fn keywords(&self) -> Vec<&'static str> {
235        vec!["smtp", "smtp client", "email", "send email", "mail"]
236    }
237    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
238        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
239
240        ProtocolMetadataV2::builder()
241            .state(DevelopmentState::Experimental)
242            .implementation("lettre library with STARTTLS support")
243            .llm_control("Full control over email composition (from, to, subject, body, auth)")
244            .e2e_testing("Local SMTP server or test mail service")
245            .build()
246    }
247    fn description(&self) -> &'static str {
248        "SMTP client for sending emails"
249    }
250    fn example_prompt(&self) -> &'static str {
251        "Connect to SMTP server at smtp.example.com:587 and send an email to user@example.com"
252    }
253    fn group_name(&self) -> &'static str {
254        "Email"
255    }
256
257    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
258        use crate::llm::actions::StartupExamples;
259        use serde_json::json;
260
261        StartupExamples::new(
262            // LLM mode: LLM controls email sending
263            json!({
264                "type": "open_client",
265                "remote_addr": "smtp.example.com:587",
266                "base_stack": "smtp",
267                "instruction": "Send a test email to user@example.com with subject 'Hello' and body 'Test message'"
268            }),
269            // Script mode: Code-based deterministic responses
270            json!({
271                "type": "open_client",
272                "remote_addr": "smtp.example.com:587",
273                "base_stack": "smtp",
274                "event_handlers": [{
275                    "event_pattern": "smtp_email_sent",
276                    "handler": {
277                        "type": "script",
278                        "language": "python",
279                        "code": "<smtp_client_handler>"
280                    }
281                }]
282            }),
283            // Static mode: Fixed email send on connect
284            json!({
285                "type": "open_client",
286                "remote_addr": "smtp.example.com:587",
287                "base_stack": "smtp",
288                "event_handlers": [
289                    {
290                        "event_pattern": "smtp_connected",
291                        "handler": {
292                            "type": "static",
293                            "actions": [{
294                                "type": "send_email",
295                                "from": "sender@example.com",
296                                "to": ["recipient@example.com"],
297                                "subject": "Test Email",
298                                "body": "Hello from NetGet SMTP client."
299                            }]
300                        }
301                    },
302                    {
303                        "event_pattern": "smtp_email_sent",
304                        "handler": {
305                            "type": "static",
306                            "actions": [{
307                                "type": "disconnect"
308                            }]
309                        }
310                    }
311                ]
312            }),
313        )
314    }
315}
316
317// Implement Client trait (client-specific functionality)
318impl Client for SmtpClientProtocol {
319    fn connect(
320        &self,
321        ctx: crate::protocol::ConnectContext,
322    ) -> std::pin::Pin<
323        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
324    > {
325        Box::pin(async move {
326            use crate::client::smtp::SmtpClient;
327            SmtpClient::connect_with_llm_actions(
328                ctx.remote_addr,
329                ctx.llm_client,
330                ctx.state,
331                ctx.status_tx,
332                ctx.client_id,
333                ctx.startup_params,
334            )
335            .await
336        })
337    }
338    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
339        let action_type = action
340            .get("type")
341            .and_then(|v| v.as_str())
342            .context("Missing 'type' field in action")?;
343
344        match action_type {
345            "send_email" => {
346                let from = action
347                    .get("from")
348                    .and_then(|v| v.as_str())
349                    .context("Missing 'from' field")?
350                    .to_string();
351
352                let to = action
353                    .get("to")
354                    .and_then(|v| v.as_array())
355                    .context("Missing 'to' field or not an array")?
356                    .iter()
357                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
358                    .collect::<Vec<_>>();
359
360                if to.is_empty() {
361                    return Err(anyhow::anyhow!(
362                        "'to' field must contain at least one email address"
363                    ));
364                }
365
366                let subject = action
367                    .get("subject")
368                    .and_then(|v| v.as_str())
369                    .context("Missing 'subject' field")?
370                    .to_string();
371
372                let body = action
373                    .get("body")
374                    .and_then(|v| v.as_str())
375                    .context("Missing 'body' field")?
376                    .to_string();
377
378                let username = action
379                    .get("username")
380                    .and_then(|v| v.as_str())
381                    .map(|s| s.to_string());
382
383                let password = action
384                    .get("password")
385                    .and_then(|v| v.as_str())
386                    .map(|s| s.to_string());
387
388                // Deliberately `Option`, not `unwrap_or(true)`. Defaulting here made "the
389                // action omitted use_tls" indistinguishable from "the action asked for TLS",
390                // so the declared `use_tls` startup parameter could never apply to a message
391                // the model did not explicitly mark. The default now lives in one place, at
392                // the point of delivery.
393                let use_tls = action.get("use_tls").and_then(|v| v.as_bool());
394
395                // Return custom result with email data
396                Ok(ClientActionResult::Custom {
397                    name: "smtp_send_email".to_string(),
398                    data: json!({
399                        "from": from,
400                        "to": to,
401                        "subject": subject,
402                        "body": body,
403                        "username": username,
404                        "password": password,
405                        "use_tls": use_tls,
406                    }),
407                })
408            }
409            "disconnect" => Ok(ClientActionResult::Disconnect),
410            _ => Err(anyhow::anyhow!(
411                "Unknown SMTP client action: {}",
412                action_type
413            )),
414        }
415    }
416}