Skip to main content

agent_first_mail/
imap_client.rs

1use crate::config::{ImapConfig, SpecialUseKind};
2use crate::error::{AppError, Result};
3use crate::types::RemoteLocation;
4use mail_parser::MessageParser;
5use rustls_connector::RustlsConnector;
6use std::net::TcpStream;
7use std::time::Duration as StdDuration;
8
9const NETWORK_TIMEOUT: StdDuration = StdDuration::from_secs(30);
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct MailboxInfo {
13    pub name: String,
14    pub delimiter: Option<String>,
15    pub attributes: Vec<String>,
16    pub special_use: Option<SpecialUseKind>,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct MoveOutcome {
21    pub keyword_set: bool,
22    pub keyword_error: Option<String>,
23    pub seen_set: bool,
24    pub seen_error: Option<String>,
25    pub moved: bool,
26    pub target_location: Option<RemoteLocation>,
27}
28
29pub struct ImapClientSession {
30    inner: Option<ImapClientSessionInner>,
31}
32
33enum ImapClientSessionInner {
34    Plain(imap::Session<TcpStream>),
35    Tls(Box<imap::Session<rustls_connector::TlsStream<TcpStream>>>),
36}
37
38impl ImapClientSession {
39    pub fn connect(config: &ImapConfig) -> Result<Self> {
40        let inner = if config.tls {
41            ImapClientSessionInner::Tls(Box::new(login_tls(config)?))
42        } else {
43            ImapClientSessionInner::Plain(login_plain(config)?)
44        };
45        Ok(Self { inner: Some(inner) })
46    }
47
48    pub fn list_mailboxes(&mut self) -> Result<Vec<MailboxInfo>> {
49        match self.inner_mut()? {
50            ImapClientSessionInner::Plain(session) => list_mailboxes(session),
51            ImapClientSessionInner::Tls(session) => list_mailboxes(session),
52        }
53    }
54
55    pub fn append_message(&mut self, folder: &str, raw_eml: &[u8], draft: bool) -> Result<()> {
56        match self.inner_mut()? {
57            ImapClientSessionInner::Plain(session) => {
58                append_message_session(session, folder, raw_eml, draft)
59            }
60            ImapClientSessionInner::Tls(session) => {
61                append_message_session(session, folder, raw_eml, draft)
62            }
63        }
64    }
65
66    pub fn uid_mark_and_move(
67        &mut self,
68        source_folder: &str,
69        uid: u64,
70        target_folder: &str,
71        rfc822_message_id: Option<&str>,
72        mark_seen: bool,
73        keyword: Option<&str>,
74    ) -> Result<MoveOutcome> {
75        match self.inner_mut()? {
76            ImapClientSessionInner::Plain(session) => uid_mark_and_move_session(
77                session,
78                source_folder,
79                uid,
80                target_folder,
81                rfc822_message_id,
82                mark_seen,
83                keyword,
84            ),
85            ImapClientSessionInner::Tls(session) => uid_mark_and_move_session(
86                session,
87                source_folder,
88                uid,
89                target_folder,
90                rfc822_message_id,
91                mark_seen,
92                keyword,
93            ),
94        }
95    }
96
97    pub fn uid_store_flags(
98        &mut self,
99        source_folder: &str,
100        uid: u64,
101        flags: &[String],
102        add: bool,
103    ) -> Result<()> {
104        match self.inner_mut()? {
105            ImapClientSessionInner::Plain(session) => {
106                uid_store_flags_session(session, source_folder, uid, flags, add)
107            }
108            ImapClientSessionInner::Tls(session) => {
109                uid_store_flags_session(session, source_folder, uid, flags, add)
110            }
111        }
112    }
113
114    pub fn find_uid_by_message_id(
115        &mut self,
116        folder: &str,
117        rfc822_message_id: &str,
118    ) -> Result<RemoteLocation> {
119        match self.inner_mut()? {
120            ImapClientSessionInner::Plain(session) => {
121                find_uid_by_message_id_session(session, folder, rfc822_message_id)
122            }
123            ImapClientSessionInner::Tls(session) => {
124                find_uid_by_message_id_session(session, folder, rfc822_message_id)
125            }
126        }
127    }
128
129    fn inner_mut(&mut self) -> Result<&mut ImapClientSessionInner> {
130        self.inner.as_mut().ok_or_else(|| {
131            AppError::new(
132                "imap_session_closed",
133                "IMAP session is already closed for this operation",
134            )
135        })
136    }
137}
138
139impl Drop for ImapClientSession {
140    fn drop(&mut self) {
141        let Some(inner) = self.inner.take() else {
142            return;
143        };
144        match inner {
145            ImapClientSessionInner::Plain(mut session) => {
146                let _ = session.logout();
147            }
148            ImapClientSessionInner::Tls(mut session) => {
149                let _ = session.logout();
150            }
151        }
152    }
153}
154
155pub(crate) fn login_plain(config: &ImapConfig) -> Result<imap::Session<TcpStream>> {
156    let stream = TcpStream::connect((config.host.as_str(), config.port))
157        .map_err(|e| AppError::new("imap_connect_failed", e.to_string()))?;
158    configure_stream_timeout(&stream)?;
159    let mut client = imap::Client::new(stream);
160    client
161        .read_greeting()
162        .map_err(|e| AppError::new("imap_greeting_failed", e.to_string()))?;
163    client
164        .login(&config.username, &config.password_secret)
165        .map_err(|e| AppError::new("imap_login_failed", e.0.to_string()))
166}
167
168pub(crate) fn login_tls(
169    config: &ImapConfig,
170) -> Result<imap::Session<rustls_connector::TlsStream<TcpStream>>> {
171    let stream = TcpStream::connect((config.host.as_str(), config.port))
172        .map_err(|e| AppError::new("imap_connect_failed", e.to_string()))?;
173    configure_stream_timeout(&stream)?;
174    let connector = RustlsConnector::new_with_webpki_root_certs()
175        .map_err(|e| AppError::new("imap_tls_failed", e.to_string()))?;
176    let tls_stream = connector
177        .connect(&config.host, stream)
178        .map_err(|e| AppError::new("imap_tls_failed", e.to_string()))?;
179    let mut client = imap::Client::new(tls_stream);
180    client
181        .read_greeting()
182        .map_err(|e| AppError::new("imap_greeting_failed", e.to_string()))?;
183    client
184        .login(&config.username, &config.password_secret)
185        .map_err(|e| AppError::new("imap_login_failed", e.0.to_string()))
186}
187
188fn configure_stream_timeout(stream: &TcpStream) -> Result<()> {
189    stream
190        .set_read_timeout(Some(NETWORK_TIMEOUT))
191        .and_then(|_| stream.set_write_timeout(Some(NETWORK_TIMEOUT)))
192        .map_err(|e| AppError::io("configure network timeout", &e))
193}
194
195pub(crate) fn list_mailboxes<T: std::io::Read + std::io::Write>(
196    session: &mut imap::Session<T>,
197) -> Result<Vec<MailboxInfo>> {
198    let names = session
199        .list(None, Some("*"))
200        .map_err(|e| AppError::new("imap_list_failed", e.to_string()))?;
201    let mut out = Vec::new();
202    for name in names.iter() {
203        let attributes = name
204            .attributes()
205            .iter()
206            .map(format_name_attribute)
207            .collect::<Vec<_>>();
208        out.push(MailboxInfo {
209            name: name.name().to_string(),
210            delimiter: name.delimiter().map(ToString::to_string),
211            special_use: special_use_from_attributes(&attributes),
212            attributes,
213        });
214    }
215    Ok(out)
216}
217
218pub(crate) fn capability_move<T: std::io::Read + std::io::Write>(
219    session: &mut imap::Session<T>,
220) -> Result<bool> {
221    let capabilities = session
222        .capabilities()
223        .map_err(|e| AppError::new("imap_capability_failed", e.to_string()))?;
224    Ok(capabilities.has_str("MOVE"))
225}
226
227fn format_name_attribute(attribute: &imap::types::NameAttribute<'_>) -> String {
228    match attribute {
229        imap::types::NameAttribute::NoInferiors => "\\Noinferiors".to_string(),
230        imap::types::NameAttribute::NoSelect => "\\Noselect".to_string(),
231        imap::types::NameAttribute::Marked => "\\Marked".to_string(),
232        imap::types::NameAttribute::Unmarked => "\\Unmarked".to_string(),
233        imap::types::NameAttribute::Custom(value) => value.to_string(),
234    }
235}
236
237pub(crate) fn create_folder<T: std::io::Read + std::io::Write>(
238    session: &mut imap::Session<T>,
239    folder: &str,
240) -> Result<()> {
241    session
242        .create(folder)
243        .map_err(|e| AppError::new("imap_create_failed", e.to_string()))
244}
245
246pub(crate) fn append_message_session<T: std::io::Read + std::io::Write>(
247    session: &mut imap::Session<T>,
248    folder: &str,
249    raw_eml: &[u8],
250    draft: bool,
251) -> Result<()> {
252    if draft {
253        session
254            .append_with_flags(folder, raw_eml, &[imap::types::Flag::Draft])
255            .map_err(|e| AppError::new("imap_append_failed", e.to_string()))
256    } else {
257        session
258            .append(folder, raw_eml)
259            .map_err(|e| AppError::new("imap_append_failed", e.to_string()))
260    }
261}
262
263pub(crate) fn append_draft_and_find_uid_session<T: std::io::Read + std::io::Write>(
264    session: &mut imap::Session<T>,
265    folder: &str,
266    raw_eml: &[u8],
267    rfc822_message_id: &str,
268) -> Result<RemoteLocation> {
269    append_message_session(session, folder, raw_eml, true)?;
270    let mailbox_status = session
271        .examine(folder)
272        .map_err(|e| AppError::new("imap_select_failed", e.to_string()))?;
273    let uid_validity = mailbox_status.uid_validity.unwrap_or(0) as u64;
274    let query = format!(
275        "HEADER Message-ID {}",
276        quote_search_string(rfc822_message_id)
277    );
278    let uids = session
279        .uid_search(query)
280        .map_err(|e| AppError::new("imap_search_failed", e.to_string()))?;
281    let uid = uids
282        .into_iter()
283        .max()
284        .ok_or_else(|| AppError::new("imap_uid_missing", "appended draft uid was not found"))?;
285    Ok(RemoteLocation {
286        mailbox_id: None,
287        mailbox_name: folder.to_string(),
288        uid_validity: Some(uid_validity),
289        uid: Some(uid as u64),
290        flags: Vec::new(),
291        observed_rfc3339: crate::store::now_rfc3339(),
292        missing_rfc3339: None,
293    })
294}
295
296pub(crate) fn uid_move_session<T: std::io::Read + std::io::Write>(
297    session: &mut imap::Session<T>,
298    source_folder: &str,
299    uid: u64,
300    target_folder: &str,
301) -> Result<()> {
302    require_move(session)?;
303    session
304        .select(source_folder)
305        .map_err(|e| AppError::new("imap_select_failed", e.to_string()))?;
306    session
307        .uid_mv(uid.to_string(), target_folder)
308        .map_err(|e| AppError::new("imap_move_failed", e.to_string()))
309}
310
311pub(crate) fn uid_mark_and_move_session<T: std::io::Read + std::io::Write>(
312    session: &mut imap::Session<T>,
313    source_folder: &str,
314    uid: u64,
315    target_folder: &str,
316    rfc822_message_id: Option<&str>,
317    mark_seen: bool,
318    keyword: Option<&str>,
319) -> Result<MoveOutcome> {
320    require_move(session)?;
321    session
322        .select(source_folder)
323        .map_err(|e| AppError::new("imap_select_failed", e.to_string()))?;
324    let (seen_set, seen_error) = if mark_seen {
325        session
326            .uid_store(uid.to_string(), "+FLAGS.SILENT (\\Seen)")
327            .map(|_| (true, None))
328            .map_err(|e| AppError::new("imap_store_failed", e.to_string()))?
329    } else {
330        (false, None)
331    };
332    let (keyword_set, keyword_error) = if let Some(keyword) = keyword {
333        let keyword_result =
334            session.uid_store(uid.to_string(), format!("+FLAGS.SILENT ({keyword})"));
335        match keyword_result {
336            Ok(_) => (true, None),
337            Err(err) => (false, Some(err.to_string())),
338        }
339    } else {
340        (false, None)
341    };
342    let moved = source_folder != target_folder;
343    if moved {
344        session
345            .uid_mv(uid.to_string(), target_folder)
346            .map_err(|e| AppError::new("imap_move_failed", e.to_string()))?;
347    }
348    let target_location = match rfc822_message_id {
349        Some(message_id) => Some(find_uid_by_message_id_session(
350            session,
351            target_folder,
352            message_id,
353        )?),
354        None => None,
355    };
356    Ok(MoveOutcome {
357        keyword_set,
358        keyword_error,
359        seen_set,
360        seen_error,
361        moved,
362        target_location,
363    })
364}
365
366pub(crate) fn uid_store_flags_session<T: std::io::Read + std::io::Write>(
367    session: &mut imap::Session<T>,
368    source_folder: &str,
369    uid: u64,
370    flags: &[String],
371    add: bool,
372) -> Result<()> {
373    session
374        .select(source_folder)
375        .map_err(|e| AppError::new("imap_select_failed", e.to_string()))?;
376    let flags = flags.join(" ");
377    let operation = if add { "+" } else { "-" };
378    session
379        .uid_store(
380            uid.to_string(),
381            format!("{operation}FLAGS.SILENT ({flags})"),
382        )
383        .map_err(|e| AppError::new("imap_store_failed", e.to_string()))?;
384    Ok(())
385}
386
387pub(crate) fn require_move<T: std::io::Read + std::io::Write>(
388    session: &mut imap::Session<T>,
389) -> Result<()> {
390    if capability_move(session)? {
391        Ok(())
392    } else {
393        Err(AppError::new(
394            "imap_move_unsupported",
395            "remote IMAP server does not advertise MOVE",
396        ))
397    }
398}
399
400pub(crate) fn quote_search_string(value: &str) -> String {
401    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
402    format!("\"{escaped}\"")
403}
404
405pub(crate) fn find_uid_by_message_id_session<T: std::io::Read + std::io::Write>(
406    session: &mut imap::Session<T>,
407    folder: &str,
408    rfc822_message_id: &str,
409) -> Result<RemoteLocation> {
410    let mailbox_status = session
411        .examine(folder)
412        .map_err(|e| AppError::new("imap_select_failed", e.to_string()))?;
413    let uid_validity = mailbox_status.uid_validity.unwrap_or(0) as u64;
414    let query = format!(
415        "HEADER Message-ID {}",
416        quote_search_string(rfc822_message_id)
417    );
418    let uid = session
419        .uid_search(query)
420        .map_err(|e| AppError::new("imap_search_failed", e.to_string()))?
421        .into_iter()
422        .max()
423        .map(|uid| uid as u64);
424    let uid = match uid {
425        Some(uid) => uid,
426        None => fetch_uid_by_message_id_session(session, rfc822_message_id)?
427            .ok_or_else(|| AppError::new("imap_uid_missing", "moved message uid was not found"))?,
428    };
429    Ok(RemoteLocation {
430        mailbox_id: None,
431        mailbox_name: folder.to_string(),
432        uid_validity: Some(uid_validity),
433        uid: Some(uid),
434        flags: Vec::new(),
435        observed_rfc3339: crate::store::now_rfc3339(),
436        missing_rfc3339: None,
437    })
438}
439
440pub(crate) fn fetch_uid_by_message_id_session<T: std::io::Read + std::io::Write>(
441    session: &mut imap::Session<T>,
442    rfc822_message_id: &str,
443) -> Result<Option<u64>> {
444    let target = normalize_message_id(rfc822_message_id);
445    let fetches = session
446        .uid_fetch("1:*", "(UID BODY.PEEK[HEADER])")
447        .map_err(|e| AppError::new("imap_fetch_failed", e.to_string()))?;
448    let mut uid = None;
449    for fetch in fetches.iter() {
450        let Some(candidate_uid) = fetch.uid else {
451            continue;
452        };
453        let Some(body) = fetch.header().or_else(|| fetch.body()) else {
454            continue;
455        };
456        if header_body_contains_message_id(body, &target) {
457            uid = Some(candidate_uid as u64);
458        }
459    }
460    Ok(uid)
461}
462
463fn header_body_contains_message_id(body: &[u8], target: &str) -> bool {
464    if let Some(message_id) = rfc822_message_id(body) {
465        if normalize_message_id(&message_id) == target {
466            return true;
467        }
468    }
469    String::from_utf8_lossy(body)
470        .split(['<', '>', ',', ';', ' ', '\t', '\r', '\n'])
471        .map(normalize_message_id)
472        .any(|message_id| message_id == target)
473}
474
475fn rfc822_message_id(raw_eml: &[u8]) -> Option<String> {
476    MessageParser::default()
477        .parse(raw_eml)
478        .and_then(|message| message.message_id().map(ToString::to_string))
479}
480
481fn normalize_message_id(value: &str) -> String {
482    value
483        .trim()
484        .trim_matches(|ch| matches!(ch, '<' | '>' | ',' | ';'))
485        .trim()
486        .to_ascii_lowercase()
487}
488
489fn special_use_from_attributes(attributes: &[String]) -> Option<SpecialUseKind> {
490    crate::config::special_use_kinds()
491        .iter()
492        .copied()
493        .find(|kind| {
494            attributes
495                .iter()
496                .any(|attribute| attribute.eq_ignore_ascii_case(kind.attribute()))
497        })
498}