Skip to main content

eoka_email/
lib.rs

1use chrono::{Duration, Utc};
2use mailparse::MailHeaderMap;
3use regex::Regex;
4
5#[derive(Debug, Clone)]
6pub struct ImapConfig {
7    pub host: String,
8    pub port: u16,
9    pub tls: bool,
10    pub username: String,
11    pub password: String,
12    pub mailbox: String,
13}
14
15impl ImapConfig {
16    pub fn new(
17        host: impl Into<String>,
18        port: u16,
19        username: impl Into<String>,
20        password: impl Into<String>,
21    ) -> Self {
22        Self {
23            host: host.into(),
24            port,
25            tls: true,
26            username: username.into(),
27            password: password.into(),
28            mailbox: "INBOX".into(),
29        }
30    }
31
32    pub fn mailbox(mut self, mailbox: impl Into<String>) -> Self {
33        self.mailbox = mailbox.into();
34        self
35    }
36
37    pub fn tls(mut self, tls: bool) -> Self {
38        self.tls = tls;
39        self
40    }
41}
42
43#[derive(Debug, Clone, Default)]
44pub struct SearchCriteria {
45    pub from: Option<String>,
46    pub subject_contains: Option<String>,
47    pub unseen_only: bool,
48    pub since_minutes: Option<i64>,
49    pub mark_seen: bool,
50}
51
52impl SearchCriteria {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    pub fn from(mut self, v: impl Into<String>) -> Self {
58        self.from = Some(v.into());
59        self
60    }
61
62    pub fn subject_contains(mut self, v: impl Into<String>) -> Self {
63        self.subject_contains = Some(v.into());
64        self
65    }
66
67    pub fn unseen_only(mut self, v: bool) -> Self {
68        self.unseen_only = v;
69        self
70    }
71
72    pub fn since_minutes(mut self, v: i64) -> Self {
73        self.since_minutes = Some(v);
74        self
75    }
76
77    pub fn mark_seen(mut self, v: bool) -> Self {
78        self.mark_seen = v;
79        self
80    }
81}
82
83#[derive(Debug, Clone)]
84pub struct WaitOptions {
85    pub timeout: Duration,
86    pub poll_interval: Duration,
87}
88
89impl WaitOptions {
90    pub fn new(timeout: Duration, poll_interval: Duration) -> Self {
91        Self {
92            timeout,
93            poll_interval,
94        }
95    }
96}
97
98#[derive(Debug, Clone)]
99pub struct EmailMessage {
100    pub uid: u32,
101    pub subject: Option<String>,
102    pub from: Option<String>,
103    pub date: Option<String>,
104    pub body_text: Option<String>,
105    pub body_html: Option<String>,
106    pub raw: Vec<u8>,
107}
108
109#[derive(thiserror::Error, Debug)]
110pub enum Error {
111    #[error("IMAP error: {0}")]
112    Imap(#[from] imap::Error),
113    #[error("TLS error: {0}")]
114    Tls(#[from] native_tls::Error),
115    #[error("Parse error: {0}")]
116    Parse(#[from] mailparse::MailParseError),
117    #[error("Timeout waiting for email")]
118    Timeout,
119    #[error("No message found")]
120    NotFound,
121    #[cfg(feature = "async")]
122    #[error("Join error: {0}")]
123    Join(String),
124}
125
126pub type Result<T> = std::result::Result<T, Error>;
127
128pub struct ImapClient {
129    session: imap::Session<imap::Connection>,
130}
131
132impl Drop for ImapClient {
133    fn drop(&mut self) {
134        let _ = self.session.logout();
135    }
136}
137
138impl ImapClient {
139    pub fn connect(config: &ImapConfig) -> Result<Self> {
140        let mut builder = imap::ClientBuilder::new(&config.host, config.port);
141        if config.tls {
142            builder = builder.mode(imap::ConnectionMode::AutoTls);
143        } else {
144            builder = builder.mode(imap::ConnectionMode::Plaintext);
145        }
146
147        let client = builder.connect()?;
148
149        let mut session = client
150            .login(&config.username, &config.password)
151            .map_err(|e| e.0)?;
152
153        session.select(&config.mailbox)?;
154
155        Ok(Self { session })
156    }
157
158    pub fn wait_for_message(
159        &mut self,
160        criteria: &SearchCriteria,
161        options: &WaitOptions,
162    ) -> Result<EmailMessage> {
163        let start = Utc::now();
164        let deadline = start + options.timeout;
165
166        loop {
167            if Utc::now() > deadline {
168                return Err(Error::Timeout);
169            }
170
171            if let Some(msg) = self.fetch_latest(criteria)? {
172                return Ok(msg);
173            }
174
175            std::thread::sleep(options.poll_interval.to_std().unwrap_or_default());
176        }
177    }
178
179    pub fn fetch_latest(&mut self, criteria: &SearchCriteria) -> Result<Option<EmailMessage>> {
180        let query = build_search_query(criteria);
181        let uids = self.session.uid_search(query)?;
182        let uid = match uids.iter().max() {
183            Some(u) => *u,
184            None => return Ok(None),
185        };
186
187        let fetches = self.session.uid_fetch(uid.to_string(), "RFC822")?;
188        let fetch = fetches.iter().next().ok_or(Error::NotFound)?;
189        let raw = fetch.body().ok_or(Error::NotFound)?.to_vec();
190
191        if criteria.mark_seen {
192            let _ = self.session.uid_store(uid.to_string(), "+FLAGS (\\Seen)");
193        }
194
195        Ok(Some(parse_message(uid, raw)?))
196    }
197}
198
199fn build_search_query(criteria: &SearchCriteria) -> String {
200    let mut parts: Vec<String> = Vec::new();
201
202    if criteria.unseen_only {
203        parts.push("UNSEEN".into());
204    }
205
206    if let Some(ref from) = criteria.from {
207        parts.push(format!("FROM \"{}\"", escape_imap(from)));
208    }
209
210    if let Some(ref subject) = criteria.subject_contains {
211        parts.push(format!("SUBJECT \"{}\"", escape_imap(subject)));
212    }
213
214    if let Some(minutes) = criteria.since_minutes {
215        let since = Utc::now() - Duration::minutes(minutes);
216        let date = since.format("%d-%b-%Y").to_string();
217        parts.push(format!("SINCE {}", date));
218    }
219
220    if parts.is_empty() {
221        "ALL".to_string()
222    } else {
223        parts.join(" ")
224    }
225}
226
227fn escape_imap(s: &str) -> String {
228    s.chars()
229        .filter(|c| !c.is_control())
230        .flat_map(|c| match c {
231            '\\' => vec!['\\', '\\'],
232            '"' => vec!['\\', '"'],
233            other => vec![other],
234        })
235        .collect()
236}
237
238fn parse_message(uid: u32, raw: Vec<u8>) -> Result<EmailMessage> {
239    let parsed = mailparse::parse_mail(&raw)?;
240
241    let headers = parsed.get_headers();
242    let subject = headers.get_first_value("Subject");
243    let from = headers.get_first_value("From");
244    let date = headers.get_first_value("Date");
245
246    let mut body_text: Option<String> = None;
247    let mut body_html: Option<String> = None;
248
249    if parsed.subparts.is_empty() {
250        let ct = parsed.ctype.mimetype.to_lowercase();
251        let body = parsed.get_body()?;
252        if ct == "text/html" {
253            body_html = Some(body);
254        } else {
255            body_text = Some(body);
256        }
257    } else {
258        for part in parsed.subparts.iter() {
259            let ct = part.ctype.mimetype.to_lowercase();
260            if ct == "text/plain" && body_text.is_none() {
261                body_text = Some(part.get_body()?);
262            } else if ct == "text/html" && body_html.is_none() {
263                body_html = Some(part.get_body()?);
264            }
265        }
266    }
267
268    Ok(EmailMessage {
269        uid,
270        subject,
271        from,
272        date,
273        body_text,
274        body_html,
275        raw,
276    })
277}
278
279#[derive(Debug, Clone, Default)]
280pub struct LinkFilter {
281    pub allow_domains: Option<Vec<String>>,
282}
283
284pub fn extract_first_link(msg: &EmailMessage, filter: &LinkFilter) -> Option<String> {
285    let hay = msg
286        .body_html
287        .as_deref()
288        .or(msg.body_text.as_deref())?;
289
290    let re = Regex::new(r#"https?://[^\s"'<>)]+"#).ok()?;
291    for m in re.find_iter(hay) {
292        let link = m.as_str().trim_end_matches(['.', ',', ';', ':', '!', '?']);
293        if link_allowed(link, filter) {
294            return Some(link.to_string());
295        }
296    }
297    None
298}
299
300fn link_allowed(link: &str, filter: &LinkFilter) -> bool {
301    let allow = match filter.allow_domains.as_ref() {
302        Some(v) if !v.is_empty() => v,
303        _ => return true,
304    };
305
306    if let Ok(url) = url::Url::parse(link) {
307        if let Some(host) = url.host_str() {
308            return allow.iter().any(|d| host.ends_with(d));
309        }
310    }
311
312    false
313}
314
315pub fn extract_code(msg: &EmailMessage, regex: &Regex) -> Option<String> {
316    let hay = msg
317        .body_text
318        .as_deref()
319        .or(msg.body_html.as_deref())?;
320
321    regex
322        .captures(hay)
323        .and_then(|c| c.get(1).or_else(|| c.get(0)))
324        .map(|m| m.as_str().to_string())
325}
326
327#[cfg(feature = "async")]
328pub mod async_client {
329    use super::*;
330    use std::sync::{Arc, Mutex};
331
332    pub struct AsyncImapClient {
333        inner: Arc<Mutex<ImapClient>>,
334    }
335
336    impl AsyncImapClient {
337        pub async fn connect(config: &ImapConfig) -> Result<Self> {
338            let cfg = config.clone();
339            let client = tokio::task::spawn_blocking(move || ImapClient::connect(&cfg))
340                .await
341                .map_err(|e| Error::Join(e.to_string()))??;
342            Ok(Self {
343                inner: Arc::new(Mutex::new(client)),
344            })
345        }
346
347        /// Poll for a matching message with async sleep between attempts.
348        /// Unlike the sync version, this releases the mutex between polls.
349        pub async fn wait_for_message(
350            &mut self,
351            criteria: &SearchCriteria,
352            options: &WaitOptions,
353        ) -> Result<EmailMessage> {
354            let deadline = Utc::now() + options.timeout;
355
356            loop {
357                if Utc::now() > deadline {
358                    return Err(Error::Timeout);
359                }
360
361                if let Some(msg) = self.fetch_latest(criteria).await? {
362                    return Ok(msg);
363                }
364
365                let sleep_ms = options
366                    .poll_interval
367                    .num_milliseconds()
368                    .max(100) as u64;
369                tokio::time::sleep(std::time::Duration::from_millis(sleep_ms)).await;
370            }
371        }
372
373        pub async fn fetch_latest(
374            &mut self,
375            criteria: &SearchCriteria,
376        ) -> Result<Option<EmailMessage>> {
377            let criteria = criteria.clone();
378            let inner = self.inner.clone();
379            tokio::task::spawn_blocking(move || {
380                let mut guard = inner.lock().unwrap();
381                guard.fetch_latest(&criteria)
382            })
383            .await
384            .map_err(|e| Error::Join(e.to_string()))?
385        }
386    }
387}
388
389#[cfg(feature = "async")]
390pub use async_client::AsyncImapClient;
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    fn make_msg(body_text: Option<&str>, body_html: Option<&str>) -> EmailMessage {
397        EmailMessage {
398            uid: 1,
399            subject: Some("Test".into()),
400            from: Some("sender@example.com".into()),
401            date: Some("Mon, 1 Jan 2024 00:00:00 +0000".into()),
402            body_text: body_text.map(String::from),
403            body_html: body_html.map(String::from),
404            raw: Vec::new(),
405        }
406    }
407
408    // --- extract_first_link ---
409
410    #[test]
411    fn extract_link_from_html() {
412        let msg = make_msg(None, Some(r#"<a href="https://example.com/verify?t=abc">Click</a>"#));
413        let link = extract_first_link(&msg, &LinkFilter::default()).unwrap();
414        assert_eq!(link, "https://example.com/verify?t=abc");
415    }
416
417    #[test]
418    fn extract_link_from_text_fallback() {
419        let msg = make_msg(Some("Visit https://example.com/link here"), None);
420        let link = extract_first_link(&msg, &LinkFilter::default()).unwrap();
421        assert_eq!(link, "https://example.com/link");
422    }
423
424    #[test]
425    fn extract_link_trims_trailing_punctuation() {
426        let msg = make_msg(Some("Go to https://example.com/page."), None);
427        let link = extract_first_link(&msg, &LinkFilter::default()).unwrap();
428        assert_eq!(link, "https://example.com/page");
429    }
430
431    #[test]
432    fn extract_link_domain_filter_allows() {
433        let msg = make_msg(Some("https://allowed.com/ok https://blocked.com/no"), None);
434        let filter = LinkFilter {
435            allow_domains: Some(vec!["allowed.com".into()]),
436        };
437        let link = extract_first_link(&msg, &filter).unwrap();
438        assert_eq!(link, "https://allowed.com/ok");
439    }
440
441    #[test]
442    fn extract_link_domain_filter_blocks() {
443        let msg = make_msg(Some("https://blocked.com/no"), None);
444        let filter = LinkFilter {
445            allow_domains: Some(vec!["allowed.com".into()]),
446        };
447        assert!(extract_first_link(&msg, &filter).is_none());
448    }
449
450    #[test]
451    fn extract_link_subdomain_match() {
452        let msg = make_msg(Some("https://sub.example.com/verify"), None);
453        let filter = LinkFilter {
454            allow_domains: Some(vec!["example.com".into()]),
455        };
456        let link = extract_first_link(&msg, &filter).unwrap();
457        assert_eq!(link, "https://sub.example.com/verify");
458    }
459
460    #[test]
461    fn extract_link_none_when_no_body() {
462        let msg = make_msg(None, None);
463        assert!(extract_first_link(&msg, &LinkFilter::default()).is_none());
464    }
465
466    // --- extract_code ---
467
468    #[test]
469    fn extract_6digit_code() {
470        let msg = make_msg(Some("Your code is 482913. Please enter it."), None);
471        let re = Regex::new(r"(\d{6})").unwrap();
472        let code = extract_code(&msg, &re).unwrap();
473        assert_eq!(code, "482913");
474    }
475
476    #[test]
477    fn extract_code_capture_group() {
478        let msg = make_msg(Some("Code: ABC-1234"), None);
479        let re = Regex::new(r"Code: ([A-Z]+-\d+)").unwrap();
480        let code = extract_code(&msg, &re).unwrap();
481        assert_eq!(code, "ABC-1234");
482    }
483
484    #[test]
485    fn extract_code_falls_back_to_group0() {
486        let msg = make_msg(Some("token 99887766"), None);
487        let re = Regex::new(r"\d{8}").unwrap();
488        let code = extract_code(&msg, &re).unwrap();
489        assert_eq!(code, "99887766");
490    }
491
492    #[test]
493    fn extract_code_prefers_text_over_html() {
494        let msg = make_msg(Some("text 111111"), Some("html 222222"));
495        let re = Regex::new(r"(\d{6})").unwrap();
496        let code = extract_code(&msg, &re).unwrap();
497        assert_eq!(code, "111111");
498    }
499
500    #[test]
501    fn extract_code_none_when_no_match() {
502        let msg = make_msg(Some("no digits here"), None);
503        let re = Regex::new(r"(\d{6})").unwrap();
504        assert!(extract_code(&msg, &re).is_none());
505    }
506
507    // --- build_search_query ---
508
509    #[test]
510    fn search_query_all() {
511        let criteria = SearchCriteria::new().unseen_only(false);
512        assert_eq!(build_search_query(&criteria), "ALL");
513    }
514
515    #[test]
516    fn search_query_unseen() {
517        let criteria = SearchCriteria::new().unseen_only(true);
518        assert_eq!(build_search_query(&criteria), "UNSEEN");
519    }
520
521    #[test]
522    fn search_query_combined() {
523        let criteria = SearchCriteria::new()
524            .unseen_only(true)
525            .from("noreply@test.com")
526            .subject_contains("Verify");
527        let q = build_search_query(&criteria);
528        assert!(q.contains("UNSEEN"));
529        assert!(q.contains(r#"FROM "noreply@test.com""#));
530        assert!(q.contains(r#"SUBJECT "Verify""#));
531    }
532
533    #[test]
534    fn search_query_since() {
535        let criteria = SearchCriteria::new().unseen_only(false).since_minutes(10);
536        let q = build_search_query(&criteria);
537        assert!(q.starts_with("SINCE "));
538    }
539
540    // --- escape_imap ---
541
542    #[test]
543    fn escape_imap_quotes_and_backslash() {
544        assert_eq!(escape_imap(r#"test"val"#), r#"test\"val"#);
545        assert_eq!(escape_imap(r"back\slash"), r"back\\slash");
546    }
547
548    #[test]
549    fn escape_imap_strips_control_chars() {
550        assert_eq!(escape_imap("hello\x00world\nok"), "helloworldok");
551    }
552
553    // --- parse_message ---
554
555    #[test]
556    fn parse_plain_text_message() {
557        let raw = b"From: sender@test.com\r\nSubject: Hello\r\nContent-Type: text/plain\r\n\r\nBody text here";
558        let msg = parse_message(42, raw.to_vec()).unwrap();
559        assert_eq!(msg.uid, 42);
560        assert_eq!(msg.subject.as_deref(), Some("Hello"));
561        assert_eq!(msg.from.as_deref(), Some("sender@test.com"));
562        assert!(msg.body_text.as_ref().unwrap().contains("Body text here"));
563        assert!(msg.body_html.is_none());
564    }
565
566    #[test]
567    fn parse_html_message() {
568        let raw = b"Subject: Hi\r\nContent-Type: text/html\r\n\r\n<b>bold</b>";
569        let msg = parse_message(1, raw.to_vec()).unwrap();
570        assert!(msg.body_html.as_ref().unwrap().contains("<b>bold</b>"));
571        assert!(msg.body_text.is_none());
572    }
573}