Skip to main content

netget/client/imap/
actions.rs

1//! IMAP 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/// IMAP client connected event
15pub static IMAP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "imap_connected",
18        "IMAP client successfully connected and authenticated",
19        json!({
20            "type": "select_mailbox",
21            "mailbox": "INBOX"
22        }),
23    )
24    .with_parameters(vec![
25        Parameter {
26            name: "remote_addr".to_string(),
27            type_hint: "string".to_string(),
28            description: "IMAP server address".to_string(),
29            required: true,
30        },
31        Parameter {
32            name: "capabilities".to_string(),
33            type_hint: "array".to_string(),
34            description: "Server capabilities".to_string(),
35            required: false,
36        },
37    ])
38});
39
40/// Raised once a mailbox is selected, so the model learns how many messages are there
41/// and can decide what to ask for next.
42pub static IMAP_MAILBOX_SELECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
43    EventType::new(
44        "imap_mailbox_selected",
45        "A mailbox was selected; reports its message counts",
46        json!({
47            "type": "search_messages",
48            "criteria": "ALL"
49        }),
50    )
51    .with_parameters(vec![
52        Parameter {
53            name: "mailbox".to_string(),
54            type_hint: "string".to_string(),
55            description: "Name of the selected mailbox".to_string(),
56            required: true,
57        },
58        Parameter {
59            name: "exists".to_string(),
60            type_hint: "number".to_string(),
61            description: "Total messages in the mailbox".to_string(),
62            required: true,
63        },
64        Parameter {
65            name: "recent".to_string(),
66            type_hint: "number".to_string(),
67            description: "Messages flagged \\Recent".to_string(),
68            required: true,
69        },
70    ])
71});
72
73/// Raised with the ids a SEARCH matched.
74pub static IMAP_SEARCH_RESULTS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
75    EventType::new(
76        "imap_search_results",
77        "A SEARCH completed; reports the matching message ids",
78        json!({
79            "type": "fetch_message",
80            "message_id": "1"
81        }),
82    )
83    .with_parameters(vec![
84        Parameter {
85            name: "criteria".to_string(),
86            type_hint: "string".to_string(),
87            description: "The search criteria that was issued".to_string(),
88            required: true,
89        },
90        Parameter {
91            name: "message_ids".to_string(),
92            type_hint: "array".to_string(),
93            description: "Sequence numbers of matching messages".to_string(),
94            required: true,
95        },
96    ])
97});
98
99/// Raised for each message a FETCH returned.
100pub static IMAP_MESSAGE_FETCHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
101    EventType::new(
102        "imap_message_fetched",
103        "A message was fetched; reports its envelope and body",
104        json!({
105            "type": "wait_for_more"
106        }),
107    )
108    .with_parameters(vec![
109        Parameter {
110            name: "message_id".to_string(),
111            type_hint: "string".to_string(),
112            description: "The message id or range that was fetched".to_string(),
113            required: true,
114        },
115        Parameter {
116            name: "messages".to_string(),
117            type_hint: "array".to_string(),
118            description: "One entry per message, each with subject, from and body".to_string(),
119            required: true,
120        },
121    ])
122});
123
124/// IMAP client protocol action handler
125pub struct ImapClientProtocol;
126
127impl ImapClientProtocol {
128    pub fn new() -> Self {
129        Self
130    }
131}
132
133// Implement Protocol trait (common functionality)
134impl Protocol for ImapClientProtocol {
135    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
136        vec![
137                ActionDefinition {
138                    name: "select_mailbox".to_string(),
139                    description: "Select a mailbox (e.g., INBOX, Sent, Drafts)".to_string(),
140                    parameters: vec![
141                        Parameter {
142                            name: "mailbox".to_string(),
143                            type_hint: "string".to_string(),
144                            description: "Mailbox name (e.g., 'INBOX')".to_string(),
145                            required: true,
146                        },
147                    ],
148                    example: json!({
149                        "type": "select_mailbox",
150                        "mailbox": "INBOX"
151                    }),
152                log_template: None,
153                },
154                ActionDefinition {
155                    name: "search_messages".to_string(),
156                    description: "Search for messages using IMAP search criteria".to_string(),
157                    parameters: vec![
158                        Parameter {
159                            name: "criteria".to_string(),
160                            type_hint: "string".to_string(),
161                            description: "Search criteria (e.g., 'UNSEEN', 'FROM sender@example.com', 'SUBJECT test')".to_string(),
162                            required: true,
163                        },
164                    ],
165                    example: json!({
166                        "type": "search_messages",
167                        "criteria": "UNSEEN"
168                    }),
169                log_template: None,
170                },
171                ActionDefinition {
172                    name: "fetch_message".to_string(),
173                    description: "Fetch a message by sequence number or UID".to_string(),
174                    parameters: vec![
175                        Parameter {
176                            name: "message_id".to_string(),
177                            type_hint: "string".to_string(),
178                            description: "Message sequence number or UID".to_string(),
179                            required: true,
180                        },
181                        Parameter {
182                            name: "parts".to_string(),
183                            type_hint: "string".to_string(),
184                            description: "What to fetch (e.g., 'BODY[]', 'BODY[HEADER]', 'FLAGS')".to_string(),
185                            required: false,
186                        },
187                    ],
188                    example: json!({
189                        "type": "fetch_message",
190                        "message_id": "1",
191                        "parts": "BODY[]"
192                    }),
193                log_template: None,
194                },
195                ActionDefinition {
196                    name: "mark_as_read".to_string(),
197                    description: "Mark a message as read (seen)".to_string(),
198                    parameters: vec![
199                        Parameter {
200                            name: "message_id".to_string(),
201                            type_hint: "string".to_string(),
202                            description: "Message sequence number or UID".to_string(),
203                            required: true,
204                        },
205                    ],
206                    example: json!({
207                        "type": "mark_as_read",
208                        "message_id": "1"
209                    }),
210                log_template: None,
211                },
212                ActionDefinition {
213                    name: "mark_as_unread".to_string(),
214                    description: "Mark a message as unread".to_string(),
215                    parameters: vec![
216                        Parameter {
217                            name: "message_id".to_string(),
218                            type_hint: "string".to_string(),
219                            description: "Message sequence number or UID".to_string(),
220                            required: true,
221                        },
222                    ],
223                    example: json!({
224                        "type": "mark_as_unread",
225                        "message_id": "1"
226                    }),
227                log_template: None,
228                },
229                ActionDefinition {
230                    name: "delete_message".to_string(),
231                    description: "Delete a message (mark for deletion and expunge)".to_string(),
232                    parameters: vec![
233                        Parameter {
234                            name: "message_id".to_string(),
235                            type_hint: "string".to_string(),
236                            description: "Message sequence number or UID".to_string(),
237                            required: true,
238                        },
239                    ],
240                    example: json!({
241                        "type": "delete_message",
242                        "message_id": "1"
243                    }),
244                log_template: None,
245                },
246                ActionDefinition {
247                    name: "list_mailboxes".to_string(),
248                    description: "List all available mailboxes".to_string(),
249                    parameters: vec![],
250                    example: json!({
251                        "type": "list_mailboxes"
252                    }),
253                log_template: None,
254                },
255                ActionDefinition {
256                    name: "disconnect".to_string(),
257                    description: "Disconnect from the IMAP server".to_string(),
258                    parameters: vec![],
259                    example: json!({
260                        "type": "disconnect"
261                    }),
262                log_template: None,
263                },
264            ]
265    }
266    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
267        vec![
268            ActionDefinition {
269                name: "fetch_message".to_string(),
270                description: "Fetch a message in response to search results".to_string(),
271                parameters: vec![Parameter {
272                    name: "message_id".to_string(),
273                    type_hint: "string".to_string(),
274                    description: "Message sequence number or UID".to_string(),
275                    required: true,
276                }],
277                example: json!({
278                    "type": "fetch_message",
279                    "message_id": "1"
280                }),
281                log_template: None,
282            },
283            ActionDefinition {
284                name: "wait_for_more".to_string(),
285                description: "Wait for more events without taking action".to_string(),
286                parameters: vec![],
287                example: json!({
288                    "type": "wait_for_more"
289                }),
290                log_template: None,
291            },
292        ]
293    }
294    fn protocol_name(&self) -> &'static str {
295        "IMAP"
296    }
297    fn get_event_types(&self) -> Vec<EventType> {
298        // Clones of the statics the client actually emits, so a declaration cannot drift
299        // from what is raised. These four used to be hand-written duplicates carrying a
300        // `"placeholder"` example action, and three of them named events nothing ever
301        // raised -- the model got one turn on connect and then went deaf.
302        vec![
303            IMAP_CLIENT_CONNECTED_EVENT.clone(),
304            IMAP_MAILBOX_SELECTED_EVENT.clone(),
305            IMAP_SEARCH_RESULTS_EVENT.clone(),
306            IMAP_MESSAGE_FETCHED_EVENT.clone(),
307        ]
308    }
309    fn stack_name(&self) -> &'static str {
310        "ETH>IP>TCP>IMAP"
311    }
312    fn keywords(&self) -> Vec<&'static str> {
313        vec!["imap", "imap client", "email", "mail", "connect to imap"]
314    }
315    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
316        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
317
318        ProtocolMetadataV2::builder()
319            .state(DevelopmentState::Experimental)
320            .implementation("async-imap library with TLS support")
321            .llm_control("Full control over mailbox operations, search, and message management")
322            .e2e_testing("Docker IMAP container or public test servers")
323            .build()
324    }
325    fn description(&self) -> &'static str {
326        "IMAP client for email retrieval and management"
327    }
328    fn example_prompt(&self) -> &'static str {
329        "Connect to IMAP server at imap.example.com:993 and fetch unread messages from INBOX"
330    }
331    fn group_name(&self) -> &'static str {
332        "Email & Messaging"
333    }
334    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
335        vec![
336            ParameterDefinition {
337                name: "username".to_string(),
338                type_hint: "string".to_string(),
339                description: "IMAP username for authentication".to_string(),
340                required: true,
341                example: json!("user@example.com"),
342                default: None,
343            },
344            ParameterDefinition {
345                name: "password".to_string(),
346                type_hint: "string".to_string(),
347                description: "IMAP password for authentication".to_string(),
348                required: true,
349                example: json!("secret123"),
350                default: None,
351            },
352            ParameterDefinition {
353                name: "use_tls".to_string(),
354                type_hint: "boolean".to_string(),
355                description: "Whether to use TLS (default: true for port 993)".to_string(),
356                required: false,
357                example: json!(false),
358                default: None,
359            },
360        ]
361    }
362
363    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
364        use crate::llm::actions::StartupExamples;
365        use serde_json::json;
366
367        StartupExamples::new(
368            // LLM mode: LLM controls email retrieval
369            json!({
370                "type": "open_client",
371                "remote_addr": "imap.example.com:993",
372                "base_stack": "imap",
373                "instruction": "Select INBOX, search for unread messages, and fetch the first one"
374            }),
375            // Script mode: Code-based deterministic responses
376            json!({
377                "type": "open_client",
378                "remote_addr": "imap.example.com:993",
379                "base_stack": "imap",
380                "event_handlers": [{
381                    "event_pattern": "imap_message_fetched",
382                    "handler": {
383                        "type": "script",
384                        "language": "python",
385                        "code": "<imap_client_handler>"
386                    }
387                }]
388            }),
389            // Static mode: Fixed IMAP operations on connect
390            json!({
391                "type": "open_client",
392                "remote_addr": "imap.example.com:993",
393                "base_stack": "imap",
394                "event_handlers": [
395                    {
396                        "event_pattern": "imap_connected",
397                        "handler": {
398                            "type": "static",
399                            "actions": [{
400                                "type": "select_mailbox",
401                                "mailbox": "INBOX"
402                            }]
403                        }
404                    },
405                    {
406                        "event_pattern": "imap_mailbox_selected",
407                        "handler": {
408                            "type": "static",
409                            "actions": [{
410                                "type": "search_messages",
411                                "criteria": "UNSEEN"
412                            }]
413                        }
414                    }
415                ]
416            }),
417        )
418    }
419}
420
421// Implement Client trait (client-specific functionality)
422impl Client for ImapClientProtocol {
423    fn connect(
424        &self,
425        ctx: crate::protocol::ConnectContext,
426    ) -> std::pin::Pin<
427        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
428    > {
429        Box::pin(async move {
430            use crate::client::imap::ImapClient;
431
432            ImapClient::connect_with_llm_actions(
433                ctx.remote_addr,
434                ctx.llm_client,
435                ctx.state,
436                ctx.status_tx,
437                ctx.client_id,
438                ctx.startup_params,
439            )
440            .await
441        })
442    }
443    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
444        let action_type = action
445            .get("type")
446            .and_then(|v| v.as_str())
447            .context("Missing 'type' field in action")?;
448
449        match action_type {
450            "select_mailbox" => {
451                let mailbox = action
452                    .get("mailbox")
453                    .and_then(|v| v.as_str())
454                    .context("Missing 'mailbox' field")?
455                    .to_string();
456
457                Ok(ClientActionResult::Custom {
458                    name: "select_mailbox".to_string(),
459                    data: json!({ "mailbox": mailbox }),
460                })
461            }
462            "search_messages" => {
463                let criteria = action
464                    .get("criteria")
465                    .and_then(|v| v.as_str())
466                    .context("Missing 'criteria' field")?
467                    .to_string();
468
469                Ok(ClientActionResult::Custom {
470                    name: "search_messages".to_string(),
471                    data: json!({ "criteria": criteria }),
472                })
473            }
474            "fetch_message" => {
475                let message_id = action
476                    .get("message_id")
477                    .and_then(|v| v.as_str())
478                    .context("Missing 'message_id' field")?
479                    .to_string();
480
481                let parts = action
482                    .get("parts")
483                    .and_then(|v| v.as_str())
484                    .unwrap_or("BODY[]")
485                    .to_string();
486
487                Ok(ClientActionResult::Custom {
488                    name: "fetch_message".to_string(),
489                    data: json!({
490                        "message_id": message_id,
491                        "parts": parts,
492                    }),
493                })
494            }
495            "mark_as_read" => {
496                let message_id = action
497                    .get("message_id")
498                    .and_then(|v| v.as_str())
499                    .context("Missing 'message_id' field")?
500                    .to_string();
501
502                Ok(ClientActionResult::Custom {
503                    name: "mark_as_read".to_string(),
504                    data: json!({ "message_id": message_id }),
505                })
506            }
507            "mark_as_unread" => {
508                let message_id = action
509                    .get("message_id")
510                    .and_then(|v| v.as_str())
511                    .context("Missing 'message_id' field")?
512                    .to_string();
513
514                Ok(ClientActionResult::Custom {
515                    name: "mark_as_unread".to_string(),
516                    data: json!({ "message_id": message_id }),
517                })
518            }
519            "delete_message" => {
520                let message_id = action
521                    .get("message_id")
522                    .and_then(|v| v.as_str())
523                    .context("Missing 'message_id' field")?
524                    .to_string();
525
526                Ok(ClientActionResult::Custom {
527                    name: "delete_message".to_string(),
528                    data: json!({ "message_id": message_id }),
529                })
530            }
531            "list_mailboxes" => Ok(ClientActionResult::Custom {
532                name: "list_mailboxes".to_string(),
533                data: json!({}),
534            }),
535            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
536            "disconnect" => Ok(ClientActionResult::Disconnect),
537            _ => Err(anyhow::anyhow!(
538                "Unknown IMAP client action: {}",
539                action_type
540            )),
541        }
542    }
543}