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}