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 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 #[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 #[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 #[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 #[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 #[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}