1pub mod actions;
3
4pub use actions::ImapClientProtocol;
5
6use anyhow::{Context, Result};
7use async_imap::{Client as ImapAsyncClient, Session};
8use futures::StreamExt;
9use std::net::SocketAddr;
10use std::sync::Arc;
11use tokio::net::TcpStream;
12use tokio::sync::{mpsc, Mutex};
13use tokio_util::compat::TokioAsyncReadCompatExt;
14use tracing::{debug, error, info, trace, warn};
15
16use crate::client::imap::actions::{
17 IMAP_CLIENT_CONNECTED_EVENT, IMAP_MAILBOX_SELECTED_EVENT, IMAP_MESSAGE_FETCHED_EVENT,
18 IMAP_SEARCH_RESULTS_EVENT,
19};
20use crate::client::llm_budget::call_llm_for_client;
21use crate::llm::actions::client_trait::{Client, ClientActionResult};
22use crate::llm::ollama_client::OllamaClient;
23use crate::llm::ClientLlmResult;
24use crate::protocol::Event;
25use crate::state::app_state::AppState;
26use crate::state::{ClientId, ClientStatus};
27
28type ImapSession = Session<tokio_util::compat::Compat<TcpStream>>;
30
31pub struct ImapClient;
33
34impl ImapClient {
35 pub async fn connect_with_llm_actions(
37 remote_addr: String,
38 llm_client: OllamaClient,
39 app_state: Arc<AppState>,
40 status_tx: mpsc::UnboundedSender<String>,
41 client_id: ClientId,
42 startup_params: Option<crate::protocol::StartupParams>,
43 ) -> Result<SocketAddr> {
44 let (username, password) = if let Some(params) = startup_params {
46 let username = params.get_string("username")?;
47 let password = params.get_string("password")?;
48
49 if params.get_optional_bool("use_tls")?.unwrap_or(false) {
60 return Err(anyhow::anyhow!(
61 "IMAP client: `use_tls: true` was requested, but this client does not \
62 implement TLS — it speaks IMAP over a plain TCP socket. Connecting anyway \
63 would send the password for `{username}` in cleartext while reporting an \
64 encrypted session. Pass `use_tls: false` to accept a plaintext connection \
65 deliberately, or terminate TLS in front of the server."
66 ));
67 }
68 (username, password)
69 } else {
70 return Err(anyhow::anyhow!(
71 "IMAP client requires startup parameters: username, password"
72 ));
73 };
74
75 info!(
76 "IMAP client {} connecting to {} (user: {})",
77 client_id, remote_addr, username
78 );
79
80 let tcp_stream = TcpStream::connect(&remote_addr)
82 .await
83 .context(format!("Failed to connect to IMAP at {}", remote_addr))?;
84
85 let local_addr = tcp_stream.local_addr()?;
86
87 let compat_stream = tcp_stream.compat();
89
90 let imap_client = ImapAsyncClient::new(compat_stream);
92
93 let session = match imap_client.login(&username, &password).await {
95 Ok(session) => {
96 info!("IMAP client {} authenticated successfully", client_id);
97 session
98 }
99 Err((e, _)) => {
100 error!("IMAP client {} authentication failed: {}", client_id, e);
101 return Err(anyhow::anyhow!("IMAP login failed: {}", e));
102 }
103 };
104
105 app_state
107 .update_client_status(client_id, ClientStatus::Connected)
108 .await;
109 let _ = status_tx.send(format!(
110 "[CLIENT] IMAP client {} connected and authenticated",
111 client_id
112 ));
113 let _ = status_tx.send("__UPDATE_UI__".to_string());
114
115 let instruction = if let Some(inst) = app_state.get_instruction_for_client(client_id).await
117 {
118 inst
119 } else {
120 return Err(anyhow::anyhow!("No instruction for IMAP client"));
121 };
122
123 let session_arc = Arc::new(Mutex::new(session));
125 let protocol = Arc::new(actions::ImapClientProtocol::new());
126
127 let command_rx =
132 crate::client::command_support::register_command_channel(&app_state, client_id).await;
133 let command_task = tokio::spawn(Self::command_loop(
134 session_arc.clone(),
135 protocol.clone(),
136 command_rx,
137 client_id,
138 llm_client.clone(),
139 app_state.clone(),
140 status_tx.clone(),
141 ));
142 app_state
143 .register_client_task(client_id, command_task)
144 .await;
145
146 let task_registrar = app_state.clone();
149 let task_handle = tokio::spawn(async move {
150 let event = Event::new(
152 &IMAP_CLIENT_CONNECTED_EVENT,
153 serde_json::json!({
154 "remote_addr": remote_addr,
155 "capabilities": vec!["IMAP4rev1"],
156 }),
157 );
158
159 let memory = app_state
160 .get_memory_for_client(client_id)
161 .await
162 .unwrap_or_default();
163
164 match call_llm_for_client(
165 &llm_client,
166 &app_state,
167 client_id.to_string(),
168 &instruction,
169 &memory,
170 Some(&event),
171 protocol.as_ref(),
172 &status_tx,
173 )
174 .await
175 {
176 Ok(ClientLlmResult {
177 actions,
178 memory_updates,
179 }) => {
180 if let Some(mem) = memory_updates {
182 app_state.set_memory_for_client(client_id, mem).await;
183 }
184
185 for action in actions {
187 if let Err(e) = Self::execute_imap_action(
188 client_id,
189 &session_arc,
190 &protocol,
191 &llm_client,
192 &app_state,
193 &status_tx,
194 action,
195 )
196 .await
197 {
198 error!("Failed to execute IMAP action: {}", e);
199 }
200 }
201 }
202 Err(e) => {
203 error!("LLM error for IMAP client {}: {}", client_id, e);
204 }
205 }
206 });
207 task_registrar
208 .register_client_task(client_id, task_handle)
209 .await;
210
211 Ok(local_addr)
212 }
213
214 #[allow(clippy::too_many_arguments)]
228 async fn command_loop(
229 session: Arc<Mutex<ImapSession>>,
230 protocol: Arc<ImapClientProtocol>,
231 mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
232 client_id: ClientId,
233 llm_client: OllamaClient,
234 app_state: Arc<AppState>,
235 status_tx: mpsc::UnboundedSender<String>,
236 ) {
237 use crate::llm::actions::protocol_trait::Protocol;
238 use crate::state::client_handles::ClientSendOutcome;
239 use crate::state::AccessLogOwner;
240
241 while let Some(command) = command_rx.recv().await {
242 let action = command.action.clone();
243 let mut disconnect = false;
244
245 let outcome: Result<ClientSendOutcome> = match protocol.execute_action(action.clone()) {
246 Err(e) => Ok(ClientSendOutcome::Rejected {
247 error: e.to_string(),
248 }),
249 Ok(ClientActionResult::Disconnect) => {
250 disconnect = true;
251 Ok(ClientSendOutcome::Disconnected)
252 }
253 Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
254 detail: "wait_for_more: nothing was asked of the server".to_string(),
255 }),
256 Ok(ClientActionResult::NoAction) => Ok(ClientSendOutcome::Executed {
257 detail: "no_action".to_string(),
258 }),
259 Ok(ClientActionResult::Custom { name, data }) => {
260 match Self::apply_action(client_id, &session, &name, &data, &status_tx).await {
261 Ok(followup) => {
262 if let Some(event) = followup {
268 let (t_session, t_protocol, t_llm, t_state, t_status) = (
269 session.clone(),
270 protocol.clone(),
271 llm_client.clone(),
272 app_state.clone(),
273 status_tx.clone(),
274 );
275 let task = tokio::spawn(async move {
276 Self::report_event(
277 client_id,
278 &t_session,
279 &t_protocol,
280 &t_llm,
281 &t_state,
282 &t_status,
283 event,
284 0,
285 )
286 .await;
287 });
288 app_state.register_client_task(client_id, task).await;
289 }
290 Ok(ClientSendOutcome::Executed {
291 detail: format!(
292 "{name} completed (async_imap frames the tagged command, so \
293 there is no byte count to report)"
294 ),
295 })
296 }
297 Err(e) => Err(e.context(format!("injected IMAP action '{name}'"))),
298 }
299 }
300 Ok(other) => Ok(ClientSendOutcome::Executed {
301 detail: format!("unsupported action result {other:?}"),
302 }),
303 };
304
305 let outcome_json = match &outcome {
306 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
307 Err(e) => serde_json::json!({"error": e.to_string()}),
308 };
309 app_state
310 .record_access_log(
311 AccessLogOwner::Client(client_id.as_u32()),
312 protocol.protocol_name(),
313 None,
314 "injected_action",
315 action,
316 vec![outcome_json],
317 )
318 .await;
319
320 if let Err(e) = &outcome {
321 error!("IMAP client {} injected action failed: {}", client_id, e);
322 let _ = status_tx.send(format!(
323 "[WARN] Client {} injected action failed: {}",
324 client_id, e
325 ));
326 }
327 let _ = status_tx.send("__UPDATE_UI__".to_string());
328 crate::client::command_support::reply(command, outcome);
329
330 if disconnect {
331 app_state
332 .update_client_status(client_id, ClientStatus::Disconnected)
333 .await;
334 let _ = tokio::time::timeout(
339 std::time::Duration::from_secs(5),
340 session.lock().await.logout(),
341 )
342 .await;
343 break;
344 }
345 }
346
347 app_state.remove_client_handle(client_id).await;
350 let _ = status_tx.send("__UPDATE_UI__".to_string());
351 }
352
353 const MAX_FOLLOWUP_DEPTH: u8 = 8;
358
359 async fn execute_imap_action(
361 client_id: ClientId,
362 session: &Arc<Mutex<ImapSession>>,
363 protocol: &Arc<ImapClientProtocol>,
364 llm_client: &OllamaClient,
365 app_state: &Arc<AppState>,
366 status_tx: &mpsc::UnboundedSender<String>,
367 action: serde_json::Value,
368 ) -> Result<()> {
369 Self::execute_imap_action_at_depth(
370 client_id, session, protocol, llm_client, app_state, status_tx, action, 0,
371 )
372 .await
373 }
374
375 #[allow(clippy::too_many_arguments)]
382 fn execute_imap_action_at_depth<'a>(
383 client_id: ClientId,
384 session: &'a Arc<Mutex<ImapSession>>,
385 protocol: &'a Arc<ImapClientProtocol>,
386 llm_client: &'a OllamaClient,
387 app_state: &'a Arc<AppState>,
388 status_tx: &'a mpsc::UnboundedSender<String>,
389 action: serde_json::Value,
390 depth: u8,
391 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
392 Box::pin(async move {
393 match protocol.execute_action(action)? {
394 ClientActionResult::Custom { name, data } => {
395 let followup =
396 Self::apply_action(client_id, session, &name, &data, status_tx).await?;
397
398 if let Some(event) = followup {
402 if depth >= Self::MAX_FOLLOWUP_DEPTH {
403 warn!(
404 "IMAP client {} reached the follow-up depth limit ({}); not \
405 reporting {} to the model",
406 client_id,
407 Self::MAX_FOLLOWUP_DEPTH,
408 event.event_type.id
409 );
410 return Ok(());
411 }
412 Self::report_event(
413 client_id, session, protocol, llm_client, app_state, status_tx, event,
414 depth,
415 )
416 .await;
417 }
418 }
419 ClientActionResult::Disconnect => {
420 info!("IMAP client {} disconnecting", client_id);
421 app_state.remove_client_handle(client_id).await;
423 app_state
424 .update_client_status(client_id, ClientStatus::Disconnected)
425 .await;
426 let _ = status_tx.send("__UPDATE_UI__".to_string());
427 }
428 ClientActionResult::WaitForMore => {
429 debug!("IMAP client {} waiting for more events", client_id);
430 }
431 _ => {}
432 }
433
434 Ok(())
435 })
436 }
437
438 #[allow(clippy::too_many_arguments)]
445 async fn report_event(
446 client_id: ClientId,
447 session: &Arc<Mutex<ImapSession>>,
448 protocol: &Arc<ImapClientProtocol>,
449 llm_client: &OllamaClient,
450 app_state: &Arc<AppState>,
451 status_tx: &mpsc::UnboundedSender<String>,
452 event: Event,
453 depth: u8,
454 ) {
455 let instruction = app_state
456 .get_instruction_for_client(client_id)
457 .await
458 .unwrap_or_default();
459 let memory = app_state
460 .get_memory_for_client(client_id)
461 .await
462 .unwrap_or_default();
463
464 match call_llm_for_client(
465 llm_client,
466 app_state,
467 client_id.to_string(),
468 &instruction,
469 &memory,
470 Some(&event),
471 protocol.as_ref(),
472 status_tx,
473 )
474 .await
475 {
476 Ok(ClientLlmResult {
477 actions,
478 memory_updates,
479 }) => {
480 if let Some(mem) = memory_updates {
481 app_state.set_memory_for_client(client_id, mem).await;
482 }
483 for action in actions {
484 if let Err(e) = Self::execute_imap_action_at_depth(
485 client_id,
486 session,
487 protocol,
488 llm_client,
489 app_state,
490 status_tx,
491 action,
492 depth + 1,
493 )
494 .await
495 {
496 error!("Failed to execute IMAP action: {}", e);
497 }
498 }
499 }
500 Err(e) => {
501 error!(
505 "LLM error for IMAP client {} on {}: {}",
506 client_id, event.event_type.id, e
507 );
508 }
509 }
510 }
511
512 async fn apply_action(
517 client_id: ClientId,
518 session: &Arc<Mutex<ImapSession>>,
519 action_name: &str,
520 action_data: &serde_json::Value,
521 _status_tx: &mpsc::UnboundedSender<String>,
522 ) -> Result<Option<Event>> {
523 match action_name {
524 "select_mailbox" => {
525 let mailbox = action_data
526 .get("mailbox")
527 .and_then(|v| v.as_str())
528 .context("Missing mailbox")?;
529
530 trace!("IMAP client {} selecting mailbox: {}", client_id, mailbox);
531
532 let mut session_guard = session.lock().await;
533 let mailbox_info = session_guard
534 .select(mailbox)
535 .await
536 .context("Failed to select mailbox")?;
537
538 let exists = mailbox_info.exists;
539 let recent = mailbox_info.recent;
540
541 info!(
542 "IMAP client {} selected mailbox '{}' ({} messages, {} recent)",
543 client_id, mailbox, exists, recent
544 );
545
546 drop(session_guard);
547
548 Ok(Some(Event::new(
549 &IMAP_MAILBOX_SELECTED_EVENT,
550 serde_json::json!({
551 "mailbox": mailbox,
552 "exists": exists,
553 "recent": recent,
554 }),
555 )))
556 }
557 "search_messages" => {
558 let criteria = action_data
559 .get("criteria")
560 .and_then(|v| v.as_str())
561 .context("Missing criteria")?;
562
563 trace!("IMAP client {} searching: {}", client_id, criteria);
564
565 let mut session_guard = session.lock().await;
566 let message_ids = session_guard
567 .search(criteria)
568 .await
569 .context("Failed to search messages")?;
570
571 let id_list: Vec<u32> = message_ids.iter().cloned().collect();
572 info!(
573 "IMAP client {} found {} messages matching '{}'",
574 client_id,
575 id_list.len(),
576 criteria
577 );
578
579 drop(session_guard);
580
581 Ok(Some(Event::new(
582 &IMAP_SEARCH_RESULTS_EVENT,
583 serde_json::json!({
584 "criteria": criteria,
585 "message_ids": id_list,
586 }),
587 )))
588 }
589 "fetch_message" => {
590 let message_id = action_data
591 .get("message_id")
592 .and_then(|v| v.as_str())
593 .context("Missing message_id")?;
594
595 let parts = action_data
596 .get("parts")
597 .and_then(|v| v.as_str())
598 .unwrap_or("BODY[]");
599
600 trace!(
601 "IMAP client {} fetching message {}: {}",
602 client_id,
603 message_id,
604 parts
605 );
606
607 let mut session_guard = session.lock().await;
608 let messages = session_guard
609 .fetch(message_id, parts)
610 .await
611 .context("Failed to fetch message")?;
612
613 let mut message_list = vec![];
615 let mut messages = Box::pin(messages);
616 while let Some(Ok(fetch)) = messages.next().await {
617 message_list.push(fetch);
618 }
619
620 drop(messages);
622 drop(session_guard);
623
624 let mut messages = Vec::with_capacity(message_list.len());
626 for fetch in message_list {
627 let body = fetch
628 .body()
629 .map(|b| String::from_utf8_lossy(b).to_string())
630 .unwrap_or_default();
631
632 let envelope = fetch.envelope();
633 let subject = envelope
634 .and_then(|e| e.subject.as_ref())
635 .and_then(|s| std::str::from_utf8(s).ok())
636 .unwrap_or("(no subject)");
637
638 let from = envelope
639 .and_then(|e| e.from.as_ref())
640 .and_then(|addrs| addrs.first())
641 .and_then(|addr| addr.mailbox.as_ref())
642 .and_then(|m| std::str::from_utf8(m).ok())
643 .unwrap_or("(unknown)");
644
645 info!(
646 "IMAP client {} fetched message {}: {} from {}",
647 client_id, message_id, subject, from
648 );
649
650 messages.push(serde_json::json!({
651 "subject": subject,
652 "from": from,
653 "body": crate::utils::truncate_for_llm(&body, 8192),
656 }));
657 }
658
659 Ok(Some(Event::new(
660 &IMAP_MESSAGE_FETCHED_EVENT,
661 serde_json::json!({
662 "message_id": message_id,
663 "messages": messages,
664 }),
665 )))
666 }
667 "mark_as_read" => {
668 let message_id = action_data
669 .get("message_id")
670 .and_then(|v| v.as_str())
671 .context("Missing message_id")?;
672
673 trace!(
674 "IMAP client {} marking message {} as read",
675 client_id,
676 message_id
677 );
678
679 let mut session_guard = session.lock().await;
680 let _ = session_guard
681 .store(message_id, "+FLAGS (\\Seen)")
682 .await
683 .context("Failed to mark message as read")?;
684
685 info!(
686 "IMAP client {} marked message {} as read",
687 client_id, message_id
688 );
689 Ok(None)
690 }
691 "mark_as_unread" => {
692 let message_id = action_data
693 .get("message_id")
694 .and_then(|v| v.as_str())
695 .context("Missing message_id")?;
696
697 trace!(
698 "IMAP client {} marking message {} as unread",
699 client_id,
700 message_id
701 );
702
703 let mut session_guard = session.lock().await;
704 let _ = session_guard
705 .store(message_id, "-FLAGS (\\Seen)")
706 .await
707 .context("Failed to mark message as unread")?;
708
709 info!(
710 "IMAP client {} marked message {} as unread",
711 client_id, message_id
712 );
713 Ok(None)
714 }
715 "delete_message" => {
716 let message_id = action_data
717 .get("message_id")
718 .and_then(|v| v.as_str())
719 .context("Missing message_id")?;
720
721 trace!("IMAP client {} deleting message {}", client_id, message_id);
722
723 let mut session_guard = session.lock().await;
724 let _ = session_guard
726 .store(message_id, "+FLAGS (\\Deleted)")
727 .await
728 .context("Failed to mark message for deletion")?;
729
730 let _ = session_guard
732 .expunge()
733 .await
734 .context("Failed to expunge deleted messages")?;
735
736 info!("IMAP client {} deleted message {}", client_id, message_id);
737 Ok(None)
738 }
739 "list_mailboxes" => {
740 trace!("IMAP client {} listing mailboxes", client_id);
741
742 let mut session_guard = session.lock().await;
743 let mailboxes = session_guard
744 .list(Some(""), Some("*"))
745 .await
746 .context("Failed to list mailboxes")?;
747
748 let mut mailbox_list = vec![];
750 let mut mailboxes = Box::pin(mailboxes);
751 while let Some(Ok(mailbox)) = mailboxes.next().await {
752 mailbox_list.push(mailbox);
753 }
754
755 let mailbox_names: Vec<String> = mailbox_list
756 .iter()
757 .map(|m: &async_imap::types::Name| m.name().to_string())
758 .collect();
759
760 info!(
761 "IMAP client {} listed {} mailboxes",
762 client_id,
763 mailbox_names.len()
764 );
765
766 debug!("Mailboxes: {:?}", mailbox_names);
767 Ok(None)
768 }
769 _ => {
770 debug!("Unknown IMAP action: {}", action_name);
771 Ok(None)
772 }
773 }
774 }
775}