Skip to main content

netget/client/imap/
mod.rs

1//! IMAP client implementation
2pub 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
28/// The authenticated async-imap session. Named because it appears in four signatures.
29type ImapSession = Session<tokio_util::compat::Compat<TcpStream>>;
30
31/// IMAP client that connects to an IMAP server
32pub struct ImapClient;
33
34impl ImapClient {
35    /// Connect to an IMAP server with integrated LLM actions
36    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        // Extract authentication credentials from startup params
45        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            // `use_tls` is declared, and this protocol's CLAUDE.md described it as working
50            // ("Upgrade to TLS if port 993 or use_tls=true"). Nothing read it, and this client
51            // has no TLS support at all — `async_imap` is driven over a plain `TcpStream`. So
52            // asking for TLS produced a cleartext session carrying the password above, with
53            // both the parameter list and the documentation saying otherwise.
54            //
55            // Refusing is the fix, not silence. The project's rule is that hiding a capability
56            // is worse than declining it out loud: the caller gets a reason they can act on,
57            // and nobody hands credentials to a plaintext socket believing it is encrypted.
58            // `use_tls: false` remains valid and means what it says.
59            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        // Connect to IMAP server via TCP
81        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        // Convert tokio stream to futures-compatible stream
88        let compat_stream = tcp_stream.compat();
89
90        // Create IMAP client
91        let imap_client = ImapAsyncClient::new(compat_stream);
92
93        // Authenticate
94        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        // Update client state
106        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        // Get initial instruction
116        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        // Spawn task to handle IMAP session with LLM integration
124        let session_arc = Arc::new(Mutex::new(session));
125        let protocol = Arc::new(actions::ImapClientProtocol::new());
126
127        // The dashboard's `[ send ]` channel, registered BEFORE the connected-event LLM call
128        // that the task below makes. A dashboard-created client defaults to a `*` -> manual
129        // rule, so that call can park for minutes waiting for a human; registering after it
130        // would leave the rail reading "no command channel" for the whole park.
131        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        // Registered with AppState so stop_client can abort this task —
147        // dropping a JoinHandle only detaches it in Tokio.
148        let task_registrar = app_state.clone();
149        let task_handle = tokio::spawn(async move {
150            // Call LLM with connected event
151            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                    // Update memory
181                    if let Some(mem) = memory_updates {
182                        app_state.set_memory_for_client(client_id, mem).await;
183                    }
184
185                    // Execute initial actions from LLM
186                    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    /// Drain injected commands (the dashboard's `[ send ]`) until the channel closes - which
215    /// happens when the client is removed - or an injected `disconnect` ends the session.
216    ///
217    /// This is a task of its own rather than a `select!` arm because there is nothing to
218    /// select against: `async_imap` owns the socket and this client has no read loop. The
219    /// task is also what keeps the session usable after the connected-event handler returns.
220    ///
221    /// Injected actions go through [`Self::apply_action`] - the same function the LLM path
222    /// uses - so the IMAP command encoding exists exactly once.
223    ///
224    /// Outcome semantics: `async_imap` writes and reads the tagged commands itself, so this
225    /// loop can honestly claim no byte count. A verb that ran reports `Executed` naming it;
226    /// a verb the server refused is an `Err`, never a quieter `Sent`.
227    #[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                            // Report the result to the model from a task of its own. A
263                            // dashboard-created client defaults to a `*` -> manual rule, so
264                            // awaiting that call here would wedge this loop for the whole
265                            // park -- far past the dashboard's 30s send timeout, on an action
266                            // that in fact succeeded.
267                            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                // Best-effort LOGOUT so the server sees a clean close rather than a dropped
335                // socket. Bounded, because the session is unusable either way once we stop
336                // draining it and a server that never answers must not strand this task
337                // holding the client's command handle.
338                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        // Every exit lands here: drop the handle so the rail stops offering [ send ] on a
348        // session 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    /// How many times an action may be answered by an event that produces another action
354    /// before this client stops asking. Each IMAP verb reports its result back to the model,
355    /// and the model may reasonably chain SELECT -> SEARCH -> FETCH; without a bound, a model
356    /// that answers `fetch_message` with `fetch_message` would loop on the LLM forever.
357    const MAX_FOLLOWUP_DEPTH: u8 = 8;
358
359    /// Execute a single IMAP action and potentially trigger more LLM calls
360    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    /// The recursive half of [`Self::execute_imap_action`].
376    ///
377    /// Boxed because it is genuinely self-referential: running an action raises an event, the
378    /// event is answered by the model with more actions, and those actions come back here.
379    /// An `async fn` that awaits itself has an infinitely-sized future type (E0391), so the
380    /// recursion has to go through a `Pin<Box<dyn Future>>`.
381    #[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                    // Tell the model what the verb produced, and run whatever it answers with.
399                    // Without this the client executed the action and said nothing, so the
400                    // model could never chain SELECT -> SEARCH -> FETCH.
401                    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                    // Stop offering [ send ] on a session that is going away.
422                    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    /// Report an event to the model and run whatever it answers with.
439    ///
440    /// Kept out of [`Self::apply_action`] on purpose: the IMAP verbs must stay callable
441    /// without an LLM round-trip, both because the injected-command path has its own timing
442    /// and because a core that calls back into itself cannot be typed (see the boxing note
443    /// on `execute_imap_action_at_depth`).
444    #[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                // Nothing is written to the wire here: IMAP is client-driven, so the peer is
502                // not waiting on a reply and silence costs it nothing. The log carries the
503                // reason.
504                error!(
505                    "LLM error for IMAP client {} on {}: {}",
506                    client_id, event.event_type.id, e
507                );
508            }
509        }
510    }
511
512    /// Run one IMAP verb against the session.
513    ///
514    /// Returns the event the verb produced, for the caller to report to the model — this
515    /// function never calls the LLM itself. Verbs with nothing to report return `None`.
516    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                // Collect messages from stream
614                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 the stream first to release the borrow
621                drop(messages);
622                drop(session_guard);
623
624                // Process fetched messages
625                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                        // The body used to be read and dropped on the floor. It is capped
654                        // because a large message would otherwise dominate the prompt.
655                        "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                // Mark for deletion
725                let _ = session_guard
726                    .store(message_id, "+FLAGS (\\Deleted)")
727                    .await
728                    .context("Failed to mark message for deletion")?;
729
730                // Expunge
731                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                // Collect mailboxes from stream
749                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}