1mod identity;
2mod session;
3mod special_use;
4
5use identity::*;
6use session::*;
7use special_use::*;
8
9pub use special_use::resolve_special_use_from_mailboxes;
10
11use crate::config::{
12 ImapConfig, MailConfig, MailDirection, PullImportAs, SpecialUseKind, SpecialUseSource,
13 SpecialUseTarget,
14};
15use crate::error::{AppError, Result};
16use crate::imap_client::{
17 append_draft_and_find_uid_session, append_message_session, capability_move, create_folder,
18 list_mailboxes, login_plain, login_tls, require_move, uid_mark_and_move_session,
19 uid_move_session,
20};
21pub use crate::imap_client::{MailboxInfo, MoveOutcome};
22use crate::mail::parse_inbound_message;
23use crate::progress::ProgressCallback;
24use crate::types::{ImapRef, MessageFile, MessageStatus, RemoteLocation, RemoteState};
25#[cfg(test)]
26use crate::util::write_json_pretty;
27use crate::util::{canonical_flags, write_bytes_atomic, write_string_atomic};
28use chrono::{DateTime, Duration as ChronoDuration, FixedOffset, Utc};
29use mail_parser::{HeaderValue, MessageParser};
30use serde_json::{json, Map, Value};
31use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
32use std::fs;
33use std::path::Path;
34use std::time::Instant;
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct PullTarget {
38 pub id: String,
39 pub mailbox: String,
40 pub import_as: PullImportAs,
41 pub direction: MailDirection,
42}
43
44pub fn resolve_pull_targets(
45 mail_config: &MailConfig,
46 imap: &ImapConfig,
47 ids: &[String],
48) -> Result<Vec<PullTarget>> {
49 let ids = mail_config.selected_pull_ids(ids)?;
50 let needs_list = ids.iter().any(|id| {
51 mail_config
52 .mailbox(id)
53 .ok()
54 .and_then(|mailbox| mailbox.special_use.as_ref())
55 .is_some()
56 });
57 let mailboxes = if needs_list {
58 Some(fetch_mailboxes(imap)?)
59 } else {
60 None
61 };
62 let mut targets = Vec::new();
63 let mut resolved_names = BTreeSet::new();
64 for id in ids {
65 let configured = mail_config.mailbox(&id)?;
66 let pull_action = mail_config.pull_action(&id)?;
67 let mailbox = match configured.mailbox_name.as_deref() {
68 Some(mailbox) => mailbox.to_string(),
69 None => {
70 let special_use = configured.special_use.as_deref().ok_or_else(|| {
71 AppError::new(
72 "config_invalid",
73 format!("mailboxes.{id} is missing mailbox selector"),
74 )
75 })?;
76 let matches = mailboxes
77 .as_ref()
78 .map(|items| {
79 items
80 .iter()
81 .filter(|mailbox| {
82 mailbox
83 .attributes
84 .iter()
85 .any(|attribute| attribute.eq_ignore_ascii_case(special_use))
86 })
87 .collect::<Vec<_>>()
88 })
89 .unwrap_or_default();
90 match matches.as_slice() {
91 [mailbox] => mailbox.name.clone(),
92 [] => {
93 return Err(AppError::new(
94 "imap_mailbox_unresolved",
95 format!(
96 "mailboxes.{id}.special_use {special_use} matched no remote mailbox"
97 ),
98 ));
99 }
100 _ => {
101 return Err(AppError::new(
102 "imap_mailbox_ambiguous",
103 format!(
104 "mailboxes.{id}.special_use {special_use} matched multiple remote mailboxes"
105 ),
106 ));
107 }
108 }
109 }
110 };
111 if !resolved_names.insert(mailbox.clone()) {
112 return Err(AppError::new(
113 "imap_mailbox_ambiguous",
114 format!("multiple selected mailbox ids resolve to remote mailbox {mailbox}"),
115 ));
116 }
117 targets.push(PullTarget {
118 id,
119 mailbox,
120 import_as: pull_action.import_as,
121 direction: pull_action.direction,
122 });
123 }
124 Ok(targets)
125}
126
127pub fn pull_workspace(
128 root: &Path,
129 mail_config: &MailConfig,
130 config: &ImapConfig,
131 targets: &[PullTarget],
132 progress: Option<&mut ProgressCallback<'_>>,
133) -> Result<Value> {
134 let mut progress = progress;
135 if config.tls {
136 let mut session = login_tls(config)?;
137 let result =
138 pull_workspace_session(root, mail_config, targets, &mut session, &mut progress);
139 let _ = session.logout();
140 result
141 } else {
142 let mut session = login_plain(config)?;
143 let result =
144 pull_workspace_session(root, mail_config, targets, &mut session, &mut progress);
145 let _ = session.logout();
146 result
147 }
148}
149
150pub fn remote_test(config: &ImapConfig) -> Result<Value> {
151 let started = Instant::now();
152 let move_supported = if config.tls {
153 let mut session = login_tls(config)?;
154 let move_supported = capability_move(&mut session)?;
155 session
156 .logout()
157 .map_err(|e| AppError::new("imap_logout_failed", e.to_string()))?;
158 move_supported
159 } else {
160 let mut session = login_plain(config)?;
161 let move_supported = capability_move(&mut session)?;
162 session
163 .logout()
164 .map_err(|e| AppError::new("imap_logout_failed", e.to_string()))?;
165 move_supported
166 };
167 Ok(json!({
168 "code": "remote_test_result",
169 "ok": true,
170 "host": config.host,
171 "port": config.port,
172 "tls": config.tls,
173 "capabilities": {
174 "move": move_supported
175 },
176 "duration_ms": started.elapsed().as_millis() as u64
177 }))
178}
179
180pub fn remote_folders(config: &MailConfig, imap: &ImapConfig) -> Result<Value> {
181 let started = Instant::now();
182 let (mailboxes, targets) = if imap.tls {
183 let mut session = login_tls(imap)?;
184 let result = list_folders_json(config, &mut session);
185 let _ = session.logout();
186 result?
187 } else {
188 let mut session = login_plain(imap)?;
189 let result = list_folders_json(config, &mut session);
190 let _ = session.logout();
191 result?
192 };
193 Ok(json!({
194 "code": "remote_mailboxes",
195 "mailboxes": mailboxes,
196 "special_use_targets": targets,
197 "duration_ms": started.elapsed().as_millis() as u64
198 }))
199}
200
201pub fn remote_mkdir(config: &ImapConfig, folder: &str) -> Result<Value> {
202 let started = Instant::now();
203 if config.tls {
204 let mut session = login_tls(config)?;
205 create_folder(&mut session, folder)?;
206 let _ = session.logout();
207 } else {
208 let mut session = login_plain(config)?;
209 create_folder(&mut session, folder)?;
210 let _ = session.logout();
211 }
212 Ok(json!({
213 "code": "remote_mailbox_created",
214 "mailbox_name": folder,
215 "duration_ms": started.elapsed().as_millis() as u64
216 }))
217}
218
219pub fn resolve_special_use(
220 config: &MailConfig,
221 imap: &ImapConfig,
222 kind: SpecialUseKind,
223) -> Result<SpecialUseTarget> {
224 let mailboxes = fetch_mailboxes(imap)?;
225 Ok(resolve_special_use_from_mailboxes(config, kind, &mailboxes))
226}
227
228pub fn resolve_all_pull_folders(config: &MailConfig, imap: &ImapConfig) -> Result<Vec<String>> {
229 let mailboxes = if imap.tls {
230 let mut session = login_tls(imap)?;
231 let result = list_mailboxes(&mut session);
232 let _ = session.logout();
233 result?
234 } else {
235 let mut session = login_plain(imap)?;
236 let result = list_mailboxes(&mut session);
237 let _ = session.logout();
238 result?
239 };
240 let mut folders = Vec::new();
241 push_unique_folder(&mut folders, "INBOX".to_string());
242 for folder in &imap.mailboxes {
243 push_unique_folder(&mut folders, folder.clone());
244 }
245 for kind in [
246 SpecialUseKind::Archive,
247 SpecialUseKind::Junk,
248 SpecialUseKind::Trash,
249 SpecialUseKind::Sent,
250 SpecialUseKind::Drafts,
251 SpecialUseKind::Flagged,
252 SpecialUseKind::All,
253 ] {
254 let target = resolve_special_use_from_mailboxes(config, kind, &mailboxes);
255 if mailboxes
256 .iter()
257 .any(|mailbox| mailbox.name == target.mailbox_name)
258 {
259 push_unique_folder(&mut folders, target.mailbox_name);
260 }
261 }
262 Ok(folders)
263}
264
265pub fn append_message(
266 config: &ImapConfig,
267 folder: &str,
268 raw_eml: &[u8],
269 draft: bool,
270) -> Result<Value> {
271 let started = Instant::now();
272 if config.tls {
273 let mut session = login_tls(config)?;
274 append_message_session(&mut session, folder, raw_eml, draft)?;
275 let _ = session.logout();
276 } else {
277 let mut session = login_plain(config)?;
278 append_message_session(&mut session, folder, raw_eml, draft)?;
279 let _ = session.logout();
280 }
281 Ok(json!({
282 "code": "remote_append_result",
283 "mailbox_name": folder,
284 "draft": draft,
285 "size_bytes": raw_eml.len(),
286 "duration_ms": started.elapsed().as_millis() as u64
287 }))
288}
289
290pub fn append_draft_and_find_uid(
291 config: &ImapConfig,
292 folder: &str,
293 raw_eml: &[u8],
294 rfc822_message_id: &str,
295) -> Result<RemoteLocation> {
296 if config.tls {
297 let mut session = login_tls(config)?;
298 let result =
299 append_draft_and_find_uid_session(&mut session, folder, raw_eml, rfc822_message_id);
300 let _ = session.logout();
301 result
302 } else {
303 let mut session = login_plain(config)?;
304 let result =
305 append_draft_and_find_uid_session(&mut session, folder, raw_eml, rfc822_message_id);
306 let _ = session.logout();
307 result
308 }
309}
310
311pub fn uid_move(
312 config: &ImapConfig,
313 source_folder: &str,
314 uid: u64,
315 target_folder: &str,
316) -> Result<()> {
317 if config.tls {
318 let mut session = login_tls(config)?;
319 let result = uid_move_session(&mut session, source_folder, uid, target_folder);
320 let _ = session.logout();
321 result
322 } else {
323 let mut session = login_plain(config)?;
324 let result = uid_move_session(&mut session, source_folder, uid, target_folder);
325 let _ = session.logout();
326 result
327 }
328}
329
330pub fn uid_mark_and_move(
331 config: &ImapConfig,
332 source_folder: &str,
333 uid: u64,
334 target_folder: &str,
335 rfc822_message_id: Option<&str>,
336 mark_seen: bool,
337 keyword: Option<&str>,
338) -> Result<MoveOutcome> {
339 if config.tls {
340 let mut session = login_tls(config)?;
341 let result = uid_mark_and_move_session(
342 &mut session,
343 source_folder,
344 uid,
345 target_folder,
346 rfc822_message_id,
347 mark_seen,
348 keyword,
349 );
350 let _ = session.logout();
351 result
352 } else {
353 let mut session = login_plain(config)?;
354 let result = uid_mark_and_move_session(
355 &mut session,
356 source_folder,
357 uid,
358 target_folder,
359 rfc822_message_id,
360 mark_seen,
361 keyword,
362 );
363 let _ = session.logout();
364 result
365 }
366}
367
368pub fn uid_store_flags(
369 config: &ImapConfig,
370 source_folder: &str,
371 uid: u64,
372 flags: &[String],
373) -> Result<()> {
374 uid_store_flags_with_operation(config, source_folder, uid, flags, true)
375}
376
377pub fn uid_remove_flags(
378 config: &ImapConfig,
379 source_folder: &str,
380 uid: u64,
381 flags: &[String],
382) -> Result<()> {
383 uid_store_flags_with_operation(config, source_folder, uid, flags, false)
384}
385
386pub fn fetch_uid_snapshots(config: &ImapConfig) -> Result<Vec<FolderUidSnapshot>> {
387 if config.tls {
388 let mut session = login_tls(config)?;
389 let result = fetch_uid_snapshots_session(&mut session, &config.mailboxes);
390 let _ = session.logout();
391 result
392 } else {
393 let mut session = login_plain(config)?;
394 let result = fetch_uid_snapshots_session(&mut session, &config.mailboxes);
395 let _ = session.logout();
396 result
397 }
398}
399
400pub fn ensure_move_supported(config: &ImapConfig) -> Result<()> {
401 if config.tls {
402 let mut session = login_tls(config)?;
403 let result = require_move(&mut session);
404 let _ = session.logout();
405 result
406 } else {
407 let mut session = login_plain(config)?;
408 let result = require_move(&mut session);
409 let _ = session.logout();
410 result
411 }
412}
413
414#[derive(Clone, Debug)]
415pub struct RemoteMessage {
416 pub mailbox: String,
417 pub uid_validity: u64,
418 pub uid: u64,
419 pub flags: Vec<String>,
420 pub raw_eml: Vec<u8>,
421}
422
423#[derive(Clone, Debug)]
424struct RemoteEnvelope {
425 mailbox: String,
426 uid_validity: u64,
427 uid: u64,
428 flags: Vec<String>,
429 header: Vec<u8>,
430}
431
432#[derive(Clone, Debug, PartialEq, Eq)]
433pub struct FolderUidSnapshot {
434 pub mailbox: String,
435 pub uid_validity: u64,
436 pub uids: BTreeSet<u64>,
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use std::path::PathBuf;
443 use std::time::{SystemTime, UNIX_EPOCH};
444
445 fn temp_root(name: &str) -> PathBuf {
446 let stamp = SystemTime::now()
447 .duration_since(UNIX_EPOCH)
448 .map(|d| d.as_nanos())
449 .unwrap_or(0);
450 std::env::temp_dir().join(format!("afmail-imap-{name}-{}-{stamp}", std::process::id()))
451 }
452
453 fn mailbox(name: &str, attributes: &[&str]) -> MailboxInfo {
454 let attributes = attributes
455 .iter()
456 .map(|attribute| (*attribute).to_string())
457 .collect::<Vec<_>>();
458 MailboxInfo {
459 name: name.to_string(),
460 delimiter: Some("/".to_string()),
461 special_use: special_use_from_attributes(&attributes),
462 attributes,
463 }
464 }
465
466 fn case_collection(
467 case_uid: &str,
468 case_name: &str,
469 message_id: &str,
470 ) -> crate::types::CaseMessages {
471 let mut collection =
472 crate::types::CaseMessages::new_case(case_uid, case_name, "2026-05-22T10:00:00Z");
473 collection.upsert_item(message_id, None, "2026-05-22T10:00:00Z");
474 collection
475 }
476
477 #[test]
478 fn resolve_special_use_prefers_config_attribute_then_fallback_name() {
479 let mut cfg = MailConfig::default();
480 if let Some(archive) = cfg.mailboxes.get_mut("archive") {
481 archive.mailbox_name = Some("Configured Archive".to_string());
482 archive.special_use = None;
483 }
484 let mailboxes = vec![
485 mailbox("RFC Archive", &["\\Archive"]),
486 mailbox("Spam", &[]),
487 mailbox("Trash", &[]),
488 ];
489 let archive = resolve_special_use_from_mailboxes(&cfg, SpecialUseKind::Archive, &mailboxes);
490 assert_eq!(archive.mailbox_name, "Configured Archive");
491 assert_eq!(archive.source, SpecialUseSource::Mailboxes);
492 assert_eq!(archive.attribute, "\\Archive");
493 assert!(archive.can_move_to);
494
495 let junk = resolve_special_use_from_mailboxes(&cfg, SpecialUseKind::Junk, &mailboxes);
496 assert_eq!(junk.mailbox_name, "Spam");
497 assert_eq!(junk.source, SpecialUseSource::FallbackName);
498 assert_eq!(junk.flag, Some("$Junk".to_string()));
499
500 let trash = resolve_special_use_from_mailboxes(&cfg, SpecialUseKind::Trash, &mailboxes);
501 assert_eq!(trash.mailbox_name, "Trash");
502 assert_eq!(trash.source, SpecialUseSource::FallbackName);
503 }
504
505 #[test]
506 fn save_remote_message_writes_three_files_and_triage() {
507 let root = temp_root("save");
508 let _ = fs::create_dir_all(root.join(".afmail/messages"));
509 let _ = fs::create_dir_all(root.join("triage"));
510 let raw = b"Message-ID: <m1@example.com>\r\nFrom: Alice <alice@example.com>\r\nTo: Me <me@example.com>\r\nSubject: Hello\r\nContent-Type: text/plain\r\n\r\nHello";
511 let result = save_remote_message(
512 &root,
513 RemoteMessage {
514 mailbox: "INBOX".to_string(),
515 uid_validity: 10,
516 uid: 20,
517 flags: Vec::new(),
518 raw_eml: raw.to_vec(),
519 },
520 &CaseSuggestion::default(),
521 &PullTarget {
522 id: "inbox".to_string(),
523 mailbox: "INBOX".to_string(),
524 import_as: PullImportAs::Triage,
525 direction: MailDirection::Inbound,
526 },
527 chrono::Offset::fix(&chrono::Utc),
528 &MailConfig::default(),
529 );
530 assert!(result.is_ok());
531 let id = result.map(|saved| saved.message_id).unwrap_or_default();
532 assert!(root.join(format!(".afmail/messages/{id}.eml")).exists());
533 assert!(!root
534 .join(format!(".afmail/messages/{id}.state.json"))
535 .exists());
536 assert!(root
537 .join(format!(".afmail/messages/{id}.remote.json"))
538 .exists());
539 assert!(!root.join(format!(".afmail/messages/{id}.txt")).exists());
540 assert!(!root.join(format!(".afmail/messages/{id}.json")).exists());
541 assert!(root.join(format!("messages/{id}.json")).exists());
542 assert!(root.join(format!("triage/{id}.md")).exists());
543 let _ = fs::remove_dir_all(root);
544 }
545
546 #[test]
547 fn message_id_carries_date_in_configured_offset() {
548 let root = temp_root("id-date");
549 let _ = fs::create_dir_all(root.join(".afmail/messages"));
550 let raw = b"Message-ID: <tz1@example.com>\r\nDate: Mon, 16 Jun 2025 02:24:07 +0800\r\nFrom: a@example.com\r\nSubject: x\r\n\r\nhi".to_vec();
551 let remote = RemoteMessage {
552 mailbox: "INBOX".to_string(),
553 uid_validity: 1,
554 uid: 1,
555 flags: Vec::new(),
556 raw_eml: raw,
557 };
558 let utc = chrono::Offset::fix(&chrono::Utc);
559 let plus8 =
560 FixedOffset::east_opt(8 * 3600).unwrap_or_else(|| chrono::Offset::fix(&chrono::Utc));
561 let id_utc = stable_message_id(&root, &remote, utc);
562 let id_plus8 = stable_message_id(&root, &remote, plus8);
563 assert!(id_utc.starts_with("message_20250615_"), "{id_utc}");
565 assert!(id_plus8.starts_with("message_20250616_"), "{id_plus8}");
566 assert_eq!(id_plus8, stable_message_id(&root, &remote, plus8));
568 assert_eq!(id_utc.rsplit('_').next(), id_plus8.rsplit('_').next());
569 let _ = fs::remove_dir_all(root);
570 }
571
572 #[test]
573 fn reply_headers_match_suggested_case_uids() {
574 let root = temp_root("suggest");
575 let _ = fs::create_dir_all(root.join(".afmail/messages"));
576 let existing = MessageFile {
577 schema_name: "message".to_string(),
578 schema_version: 1,
579 message_id: "message_case_1".to_string(),
580 rfc822_message_id: Some("<Case-One@Example.com>".to_string()),
581 in_reply_to: None,
582 references: Vec::new(),
583 remote: None,
584 direction: Some("inbound".to_string()),
585 subject: Some("Case".to_string()),
586 from: Some("alice@example.com".to_string()),
587 to: Vec::new(),
588 cc: Vec::new(),
589 bcc: Vec::new(),
590 reply_to: Vec::new(),
591 sender: None,
592 delivered_to: Vec::new(),
593 x_original_to: Vec::new(),
594 envelope_to: Vec::new(),
595 list_id: None,
596 mailing_list_headers: Vec::new(),
597 authentication: crate::types::MessageAuthentication::default(),
598 received_rfc3339: None,
599 sent_rfc3339: None,
600 body_text: String::new(),
601 eml_path: None,
602 attachments: Vec::new(),
603 workspace: crate::types::WorkspaceState {
604 status: "case".to_string(),
605 archive_uid: None,
606 archived_rfc3339: None,
607 origin: None,
608 remote_sync: None,
609 push: None,
610 },
611 };
612 let second = MessageFile {
613 message_id: "message_case_2".to_string(),
614 rfc822_message_id: Some("<case-two@example.com>".to_string()),
615 workspace: crate::types::WorkspaceState {
616 status: "case".to_string(),
617 archive_uid: None,
618 archived_rfc3339: None,
619 origin: None,
620 remote_sync: None,
621 push: None,
622 },
623 ..existing.clone()
624 };
625 let _ = fs::write(
626 root.join(".afmail/messages/message_case_1.eml"),
627 "Message-ID: <Case-One@Example.com>\r\nFrom: Alice <alice@example.com>\r\nTo: Me <me@example.com>\r\nSubject: Case\r\n\r\nCase",
628 );
629 let _ = crate::store::Workspace::at(&root).write_message_artifacts(&existing);
630 let _ = fs::write(
631 root.join(".afmail/messages/message_case_2.eml"),
632 "Message-ID: <case-two@example.com>\r\nFrom: Alice <alice@example.com>\r\nTo: Me <me@example.com>\r\nSubject: Case\r\n\r\nCase",
633 );
634 let _ = crate::store::Workspace::at(&root).write_message_artifacts(&second);
635 let _ = fs::create_dir_all(root.join("cases/open/case-one/data"));
636 let _ = fs::create_dir_all(root.join("cases/open/case-two/data"));
637 let _ = write_json_pretty(
638 &root.join("cases/open/case-one/data/case.json"),
639 &case_collection("case-one", "Case One", "message_case_1"),
640 );
641 let _ = write_json_pretty(
642 &root.join("cases/open/case-two/data/case.json"),
643 &case_collection("case-two", "Case Two", "message_case_2"),
644 );
645 let index = load_existing_remote_index(&root);
646 assert!(index.is_ok());
647 let raw = concat!(
648 "Message-ID: <reply@example.com>\r\n",
649 "In-Reply-To: <case-one@example.com>\r\n",
650 "References: <other@example.com> <case-one@example.com> <case-two@example.com>\r\n",
651 "From: Alice <alice@example.com>\r\n",
652 "To: Me <me@example.com>\r\n",
653 "Subject: Re: Case\r\n\r\n",
654 "Reply"
655 );
656 let suggestion = index.unwrap_or_default().suggest_case(raw.as_bytes());
657 assert_eq!(
658 suggestion.case_uids,
659 vec!["case-one".to_string(), "case-two".to_string()]
660 );
661 let _ = fs::remove_dir_all(root);
662 }
663}