Skip to main content

protonmail_client/
client.rs

1//! Proton Mail IMAP client with typestate access control
2//!
3//! `ProtonClient<M>` is parameterised by an access mode:
4//!
5//! - [`ReadOnly`]  -- only read operations (list, fetch, search)
6//! - [`ReadWrite`] -- read **and** write operations (move, flag,
7//!   archive)
8//!
9//! This prevents accidental use of destructive operations when only
10//! read access is intended.
11//!
12//! ```rust,no_run
13//! use protonmail_client::{ImapConfig, ProtonClient, ReadOnly, ReadWrite};
14//!
15//! let cfg = ImapConfig::from_env().unwrap();
16//!
17//! // Read-only client -- write methods are not available.
18//! let reader: ProtonClient<ReadOnly> = ProtonClient::new(cfg.clone());
19//!
20//! // Read-write client -- all methods are available.
21//! let writer: ProtonClient<ReadWrite> = ProtonClient::new(cfg);
22//! ```
23//!
24//! A read-only client cannot call write operations — this fails to
25//! compile:
26//!
27//! ```compile_fail
28//! use protonmail_client::{Flag, Folder, ImapConfig, ProtonClient};
29//!
30//! let cfg = ImapConfig::from_env().unwrap();
31//! let client: ProtonClient = ProtonClient::new(cfg);
32//! // ERROR: no method named `add_flag` found for `ProtonClient<ReadOnly>`
33//! let _ = client.add_flag(1, &Folder::Inbox, &Flag::Seen);
34//! ```
35//!
36//! ```compile_fail
37//! use protonmail_client::{Folder, ImapConfig, ProtonClient};
38//!
39//! let cfg = ImapConfig::from_env().unwrap();
40//! let client: ProtonClient = ProtonClient::new(cfg);
41//! // ERROR: no method named `move_to_folder` found for `ProtonClient<ReadOnly>`
42//! let _ = client.move_to_folder(1, &Folder::Inbox, &Folder::Trash);
43//! ```
44//!
45//! ```compile_fail
46//! use protonmail_client::{Folder, ImapConfig, ProtonClient};
47//!
48//! let cfg = ImapConfig::from_env().unwrap();
49//! let client: ProtonClient = ProtonClient::new(cfg);
50//! // ERROR: no method named `archive` found for `ProtonClient<ReadOnly>`
51//! let _ = client.archive(1, &Folder::Inbox);
52//! ```
53
54use std::marker::PhantomData;
55
56use crate::config::ImapConfig;
57use crate::connection::{self, ImapSession};
58use crate::error::{Error, Result};
59use crate::flag::Flag;
60use crate::folder::Folder;
61use chrono::NaiveDate;
62use email_extract::{Email, parse_email};
63use futures::{StreamExt, pin_mut};
64use tracing::{info, warn};
65
66// ── Access-mode markers ────────────────────────────────────────────
67
68/// Marker: read-only access. Write methods are not available.
69#[derive(Debug, Clone, Copy)]
70pub struct ReadOnly;
71
72/// Marker: read-write access. All methods are available.
73#[derive(Debug, Clone, Copy)]
74pub struct ReadWrite;
75
76// ── Client ─────────────────────────────────────────────────────────
77
78/// IMAP client for Proton Mail via Proton Bridge.
79///
80/// The type parameter `M` controls which operations are available:
81///
82/// | `M`         | Read ops | Write ops |
83/// |-------------|----------|-----------|
84/// | `ReadOnly`  | yes      | no        |
85/// | `ReadWrite` | yes      | yes       |
86pub struct ProtonClient<M = ReadOnly> {
87    config: ImapConfig,
88    _mode: PhantomData<M>,
89}
90
91impl<M> ProtonClient<M> {
92    #[must_use]
93    pub const fn new(config: ImapConfig) -> Self {
94        Self {
95            config,
96            _mode: PhantomData,
97        }
98    }
99}
100
101// ── Read operations (available on any M) ───────────────────────────
102
103impl<M: Send + Sync> ProtonClient<M> {
104    /// List all available IMAP folders.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the connection or LIST command fails.
109    pub async fn list_folders(&self) -> Result<Vec<String>> {
110        let mut session = connection::connect(&self.config).await?;
111
112        let mut folder_stream = session
113            .list(Some(""), Some("*"))
114            .await
115            .map_err(|e| Error::Imap(format!("List folders failed: {e}")))?;
116
117        let mut names = Vec::new();
118        while let Some(item) = folder_stream.next().await {
119            if let Ok(name) = item {
120                names.push(name.name().to_string());
121            }
122        }
123        drop(folder_stream);
124
125        session.logout().await.ok();
126        Ok(names)
127    }
128
129    /// Fetch a single email by UID from a folder.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the connection, SELECT, or FETCH fails,
134    /// or if the message body cannot be parsed.
135    pub async fn fetch_uid(&self, folder: &Folder, uid: u32) -> Result<Email> {
136        let mut session = connection::connect(&self.config).await?;
137        connection::select(&mut session, folder.as_str()).await?;
138
139        let email = Self::fetch_single(&mut session, uid).await?;
140
141        session.logout().await.ok();
142        Ok(email)
143    }
144
145    /// Fetch all unseen emails from a folder.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the connection, SELECT, or SEARCH fails.
150    pub async fn fetch_unseen(&self, folder: &Folder) -> Result<Vec<Email>> {
151        self.search(folder, "UNSEEN").await
152    }
153
154    /// Fetch all emails from a folder.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the connection, SELECT, or SEARCH fails.
159    pub async fn fetch_all(&self, folder: &Folder) -> Result<Vec<Email>> {
160        self.search(folder, "ALL").await
161    }
162
163    /// Fetch the N most recent emails from a folder.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the connection, SELECT, SEARCH, or
168    /// FETCH fails.
169    pub async fn fetch_last_n(&self, folder: &Folder, n: usize) -> Result<Vec<Email>> {
170        let mut session = connection::connect(&self.config).await?;
171        connection::select(&mut session, folder.as_str()).await?;
172
173        let uids = session
174            .uid_search("ALL")
175            .await
176            .map_err(|e| Error::Imap(format!("Search failed: {e}")))?;
177
178        let mut uid_list: Vec<u32> = uids.into_iter().collect();
179        uid_list.sort_unstable();
180
181        let start = uid_list.len().saturating_sub(n);
182        let recent_uids = &uid_list[start..];
183
184        if recent_uids.is_empty() {
185            session.logout().await.ok();
186            return Ok(vec![]);
187        }
188
189        info!("Fetching {} most recent messages", recent_uids.len());
190
191        let mut emails = Self::fetch_by_uids(&mut session, recent_uids).await?;
192        emails.sort_by(|a, b| b.date.cmp(&a.date));
193
194        session.logout().await.ok();
195        Ok(emails)
196    }
197
198    /// Fetch emails within a date range from a folder.
199    ///
200    /// IMAP semantics: SINCE >= date, BEFORE < date.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the connection, SELECT, or SEARCH fails.
205    pub async fn fetch_date_range(
206        &self,
207        folder: &Folder,
208        since: NaiveDate,
209        before: NaiveDate,
210    ) -> Result<Vec<Email>> {
211        let since_str = since.format("%-d-%b-%Y").to_string();
212        let before_str = before.format("%-d-%b-%Y").to_string();
213        let query = format!("SINCE {since_str} BEFORE {before_str}");
214
215        let mut emails = self.search(folder, &query).await?;
216        emails.sort_by(|a, b| b.date.cmp(&a.date));
217        Ok(emails)
218    }
219
220    /// Search emails using an arbitrary IMAP search query.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the connection, SELECT, or SEARCH fails.
225    pub async fn search(&self, folder: &Folder, query: &str) -> Result<Vec<Email>> {
226        let mut session = connection::connect(&self.config).await?;
227        connection::select(&mut session, folder.as_str()).await?;
228
229        let uids = session
230            .uid_search(query)
231            .await
232            .map_err(|e| Error::Imap(format!("Search failed: {e}")))?;
233
234        let uid_list: Vec<u32> = uids.into_iter().collect();
235        if uid_list.is_empty() {
236            session.logout().await.ok();
237            return Ok(vec![]);
238        }
239
240        info!("Found {} messages matching '{}'", uid_list.len(), query);
241
242        let emails = Self::fetch_by_uids(&mut session, &uid_list).await?;
243
244        session.logout().await.ok();
245        Ok(emails)
246    }
247
248    // -- private helpers (read) --
249
250    async fn fetch_by_uids(session: &mut ImapSession, uids: &[u32]) -> Result<Vec<Email>> {
251        let mut emails = Vec::new();
252
253        for uid in uids {
254            match Self::fetch_single(session, *uid).await {
255                Ok(email) => emails.push(email),
256                Err(e) => {
257                    warn!("Failed to fetch UID {}: {}", uid, e);
258                }
259            }
260        }
261
262        Ok(emails)
263    }
264
265    async fn fetch_single(session: &mut ImapSession, uid: u32) -> Result<Email> {
266        let uid_set = format!("{uid}");
267        let mut messages = session
268            .uid_fetch(&uid_set, "(BODY.PEEK[])")
269            .await
270            .map_err(|e| Error::Imap(format!("Fetch failed: {e}")))?;
271
272        if let Some(msg_result) = messages.next().await {
273            let msg = msg_result.map_err(|e| Error::Imap(format!("Fetch error: {e}")))?;
274            if let Some(body) = msg.body() {
275                return parse_email(uid, body).map_err(|e| Error::Parse(e.to_string()));
276            }
277        }
278
279        Err(Error::Imap(format!("No body found for UID {uid}")))
280    }
281}
282
283// ── Write operations (only on ReadWrite) ───────────────────────────
284
285impl ProtonClient<ReadWrite> {
286    /// Move an email from one folder to another.
287    ///
288    /// Selects `from`, copies the message to `to`, marks it
289    /// `\Deleted` in the source folder, and expunges.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if any IMAP command fails.
294    pub async fn move_to_folder(&self, uid: u32, from: &Folder, to: &Folder) -> Result<()> {
295        let mut session = connection::connect(&self.config).await?;
296        connection::select(&mut session, from.as_str()).await?;
297
298        let uid_set = format!("{uid}");
299
300        // COPY to destination
301        session
302            .uid_copy(&uid_set, to.as_str())
303            .await
304            .map_err(|e| Error::Imap(format!("Copy failed: {e}")))?;
305
306        // Mark \Deleted in source
307        let mut store_stream = session
308            .uid_store(&uid_set, "+FLAGS (\\Deleted)")
309            .await
310            .map_err(|e| Error::Imap(format!("Store +Deleted failed: {e}")))?;
311        while store_stream.next().await.is_some() {}
312        drop(store_stream);
313
314        // Expunge to permanently remove
315        {
316            let expunge_stream = session
317                .expunge()
318                .await
319                .map_err(|e| Error::Imap(format!("Expunge failed: {e}")))?;
320            pin_mut!(expunge_stream);
321            while expunge_stream.next().await.is_some() {}
322        }
323
324        session.logout().await.ok();
325        Ok(())
326    }
327
328    /// Add a flag to an email.
329    ///
330    /// # Errors
331    ///
332    /// Returns an error if the connection, SELECT, or STORE fails.
333    pub async fn add_flag(&self, uid: u32, folder: &Folder, flag: &Flag) -> Result<()> {
334        let mut session = connection::connect(&self.config).await?;
335        connection::select(&mut session, folder.as_str()).await?;
336
337        let uid_set = format!("{uid}");
338        let store_arg = format!("+FLAGS ({})", flag.as_imap_str());
339
340        let mut stream = session
341            .uid_store(&uid_set, &store_arg)
342            .await
343            .map_err(|e| Error::Imap(format!("Store failed: {e}")))?;
344        while stream.next().await.is_some() {}
345        drop(stream);
346
347        session.logout().await.ok();
348        Ok(())
349    }
350
351    /// Remove a flag from an email.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if the connection, SELECT, or STORE fails.
356    pub async fn remove_flag(&self, uid: u32, folder: &Folder, flag: &Flag) -> Result<()> {
357        let mut session = connection::connect(&self.config).await?;
358        connection::select(&mut session, folder.as_str()).await?;
359
360        let uid_set = format!("{uid}");
361        let store_arg = format!("-FLAGS ({})", flag.as_imap_str());
362
363        let mut stream = session
364            .uid_store(&uid_set, &store_arg)
365            .await
366            .map_err(|e| Error::Imap(format!("Store failed: {e}")))?;
367        while stream.next().await.is_some() {}
368        drop(stream);
369
370        session.logout().await.ok();
371        Ok(())
372    }
373
374    /// Archive an email by moving it to the Archive folder.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if the move operation fails.
379    pub async fn archive(&self, uid: u32, from: &Folder) -> Result<()> {
380        self.move_to_folder(uid, from, &Folder::Archive).await
381    }
382
383    /// Remove the `\Seen` flag from all messages in a folder.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if the connection, SELECT, SEARCH, or STORE
388    /// fails.
389    pub async fn unmark_all_read(&self, folder: &Folder) -> Result<()> {
390        let mut session = connection::connect(&self.config).await?;
391        connection::select(&mut session, folder.as_str()).await?;
392
393        let uids = session
394            .uid_search("SEEN")
395            .await
396            .map_err(|e| Error::Imap(format!("Search failed: {e}")))?;
397
398        let uid_list: Vec<u32> = uids.into_iter().collect();
399        if uid_list.is_empty() {
400            session.logout().await.ok();
401            return Ok(());
402        }
403
404        let uid_set = uid_list
405            .iter()
406            .map(ToString::to_string)
407            .collect::<Vec<_>>()
408            .join(",");
409
410        let mut stream = session
411            .uid_store(&uid_set, "-FLAGS (\\Seen)")
412            .await
413            .map_err(|e| Error::Imap(format!("Store failed: {e}")))?;
414        while stream.next().await.is_some() {}
415        drop(stream);
416
417        session.logout().await.ok();
418        Ok(())
419    }
420}