1use 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
14pub 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
40pub 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
73pub 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
99pub 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
124pub struct ImapClientProtocol;
126
127impl ImapClientProtocol {
128 pub fn new() -> Self {
129 Self
130 }
131}
132
133impl 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 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 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 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 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
421impl 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}