Skip to main content

agent_first_mail/imap_pull/
mod.rs

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        // 02:24 +08:00 is the previous day in UTC, the same day in +08:00.
564        assert!(id_utc.starts_with("message_20250615_"), "{id_utc}");
565        assert!(id_plus8.starts_with("message_20250616_"), "{id_plus8}");
566        // Same offset is deterministic; the hash suffix is offset-independent.
567        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            contact: None,
604            identity: None,
605            identity_email: None,
606            identity_match: None,
607            identity_candidates: Vec::new(),
608            observed_recipient_emails: Vec::new(),
609            workspace: crate::types::WorkspaceState {
610                status: "case".to_string(),
611                archive_uid: None,
612                archived_rfc3339: None,
613                origin: None,
614                remote_sync: None,
615                push: None,
616            },
617        };
618        let second = MessageFile {
619            message_id: "message_case_2".to_string(),
620            rfc822_message_id: Some("<case-two@example.com>".to_string()),
621            workspace: crate::types::WorkspaceState {
622                status: "case".to_string(),
623                archive_uid: None,
624                archived_rfc3339: None,
625                origin: None,
626                remote_sync: None,
627                push: None,
628            },
629            ..existing.clone()
630        };
631        let _ = fs::write(
632            root.join(".afmail/messages/message_case_1.eml"),
633            "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",
634        );
635        let _ = crate::store::Workspace::at(&root).write_message_artifacts(&existing);
636        let _ = fs::write(
637            root.join(".afmail/messages/message_case_2.eml"),
638            "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",
639        );
640        let _ = crate::store::Workspace::at(&root).write_message_artifacts(&second);
641        let _ = fs::create_dir_all(root.join("cases/open/case-one/data"));
642        let _ = fs::create_dir_all(root.join("cases/open/case-two/data"));
643        let _ = write_json_pretty(
644            &root.join("cases/open/case-one/data/case.json"),
645            &case_collection("case-one", "Case One", "message_case_1"),
646        );
647        let _ = write_json_pretty(
648            &root.join("cases/open/case-two/data/case.json"),
649            &case_collection("case-two", "Case Two", "message_case_2"),
650        );
651        let index = load_existing_remote_index(&root);
652        assert!(index.is_ok());
653        let raw = concat!(
654            "Message-ID: <reply@example.com>\r\n",
655            "In-Reply-To: <case-one@example.com>\r\n",
656            "References: <other@example.com> <case-one@example.com> <case-two@example.com>\r\n",
657            "From: Alice <alice@example.com>\r\n",
658            "To: Me <me@example.com>\r\n",
659            "Subject: Re: Case\r\n\r\n",
660            "Reply"
661        );
662        let suggestion = index.unwrap_or_default().suggest_case(raw.as_bytes());
663        assert_eq!(
664            suggestion.case_uids,
665            vec!["case-one".to_string(), "case-two".to_string()]
666        );
667        let _ = fs::remove_dir_all(root);
668    }
669}