Skip to main content

agent_first_mail/store/
remote_sync.rs

1use super::*;
2
3impl Workspace {
4    pub fn pull(&self, ids: &[String]) -> Result<Value> {
5        self.pull_with_progress(ids, None)
6    }
7
8    pub fn pull_with_progress(
9        &self,
10        ids: &[String],
11        progress: Option<&mut crate::progress::ProgressCallback<'_>>,
12    ) -> Result<Value> {
13        self.require_workspace()?;
14        let mut progress = progress;
15        let mail_config = crate::config::MailConfig::load(&self.root)?;
16        // Validate mailbox ids before requiring network credentials so typos fail clearly.
17        mail_config.selected_pull_ids(ids)?;
18        let imap_base = mail_config.require_imap()?;
19        let targets = crate::imap_pull::resolve_pull_targets(&mail_config, &imap_base, ids)?;
20        crate::progress::emit(
21            &mut progress,
22            "pull_resolve_targets",
23            json!({
24                "requested_mailbox_ids": ids,
25                "mailbox_ids": targets.iter().map(|target| target.id.clone()).collect::<Vec<_>>(),
26                "mailbox_names": targets.iter().map(|target| target.mailbox.clone()).collect::<Vec<_>>(),
27                "mailbox_count": targets.len(),
28            }),
29        );
30        let imap = mail_config.require_imap_with_mailboxes(
31            targets
32                .iter()
33                .map(|target| target.mailbox.clone())
34                .collect(),
35        )?;
36        let mut result = crate::imap_pull::pull_workspace(
37            &self.root,
38            &mail_config,
39            &imap,
40            &targets,
41            progress.as_deref_mut(),
42        )?;
43        crate::progress::emit(
44            &mut progress,
45            "pull_reconcile_start",
46            json!({
47                "mailbox_names": imap.mailboxes.clone(),
48                "mailbox_count": imap.mailboxes.len(),
49            }),
50        );
51        let reconciliation = self.reconcile_remote_missing(&imap)?;
52        let mut reconcile_progress = reconciliation.clone();
53        if let Some(map) = reconcile_progress.as_object_mut() {
54            map.insert("mailbox_count".to_string(), json!(imap.mailboxes.len()));
55        }
56        crate::progress::emit(&mut progress, "pull_reconcile_done", reconcile_progress);
57        merge_reconciliation_into_pull(&mut result, &reconciliation);
58        crate::progress::emit(&mut progress, "pull_render_start", json!({}));
59        let triage = self.refresh_triage_views()?;
60        let dispositions = self.refresh_disposition_views()?;
61        merge_triage_refresh_into_pull(&mut result, &triage);
62        merge_triage_refresh_into_pull(&mut result, &dispositions);
63        let case_view_count = self.refresh_all_case_message_views()?;
64        let archive_index_count = self.archive_message_category_ids()?.len();
65        self.refresh_archive_indexes()?;
66        let mut render_done = triage.clone();
67        if let (Some(render_map), Some(disposition_map)) =
68            (render_done.as_object_mut(), dispositions.as_object())
69        {
70            for key in [
71                "spam_count",
72                "spam_written_count",
73                "stale_spam_removed_count",
74                "trash_count",
75                "trash_written_count",
76                "stale_trash_removed_count",
77                "deleted_count",
78                "deleted_written_count",
79                "stale_deleted_removed_count",
80            ] {
81                if let Some(value) = disposition_map.get(key) {
82                    render_map.insert(key.to_string(), value.clone());
83                }
84            }
85        }
86        if let Some(map) = render_done.as_object_mut() {
87            map.insert("case_view_count".to_string(), json!(case_view_count));
88            map.insert(
89                "archive_message_category_count".to_string(),
90                json!(archive_index_count),
91            );
92        }
93        crate::progress::emit(&mut progress, "pull_render_done", render_done);
94        Ok(result)
95    }
96
97    fn reconcile_remote_missing(&self, config: &crate::config::ImapConfig) -> Result<Value> {
98        let started = Instant::now();
99        let snapshots = crate::imap_pull::fetch_uid_snapshots(config)?;
100        let snapshot_by_folder = snapshots
101            .iter()
102            .map(|snapshot| (snapshot.mailbox.clone(), snapshot))
103            .collect::<BTreeMap<_, _>>();
104        let selected_folders = config.mailboxes.iter().cloned().collect::<BTreeSet<_>>();
105
106        let mut checked_location_count = 0usize;
107        let mut missing_location_count = 0usize;
108        let mut deleted_remote_message_ids = Vec::new();
109        let mut tombstoned_message_ids = Vec::new();
110        let mut kept_message_ids = Vec::new();
111        for path in message_json_paths(&self.root)? {
112            let mut message = read_message(&path)?;
113            let active_locations = active_remote_locations(&message, &selected_folders);
114            if active_locations.is_empty() {
115                continue;
116            }
117            checked_location_count += active_locations.len();
118            let missing_locations = active_locations
119                .into_iter()
120                .filter(|location| remote_location_missing(location, &snapshot_by_folder))
121                .collect::<Vec<_>>();
122            if missing_locations.is_empty() {
123                continue;
124            }
125            missing_location_count += missing_locations.len();
126            mark_remote_locations_missing(&mut message, &missing_locations);
127            let still_has_active_remote = has_any_active_remote_location(&message);
128            if still_has_active_remote {
129                self.persist_message_remote(&message)?;
130                self.write_message_materialized_cache(&message)?;
131                kept_message_ids.push(message.message_id.clone());
132                continue;
133            }
134
135            let id = message.message_id.clone();
136            if self.message_id_is_referenced(&id)? {
137                self.persist_message_remote(&message)?;
138                self.write_message_materialized_cache(&message)?;
139                tombstoned_message_ids.push(id);
140            } else {
141                message.workspace.status = MessageStatus::DeletedRemote.as_str().to_string();
142                message.workspace.archive_uid = None;
143                message.workspace.archived_rfc3339 = None;
144                message.workspace.origin = None;
145                message.workspace.remote_sync = None;
146                message.workspace.push = None;
147                self.write_message_artifacts(&message)?;
148                deleted_remote_message_ids.push(id);
149            }
150        }
151
152        Ok(json!({
153            "checked_location_count": checked_location_count,
154            "missing_location_count": missing_location_count,
155            "deleted_remote_message_count": deleted_remote_message_ids.len(),
156            "deleted_remote_message_ids": deleted_remote_message_ids,
157            "tombstoned_message_count": tombstoned_message_ids.len(),
158            "tombstoned_message_ids": tombstoned_message_ids,
159            "kept_message_count": kept_message_ids.len(),
160            "kept_message_ids": kept_message_ids,
161            "duration_ms": started.elapsed().as_millis() as u64
162        }))
163    }
164}
165
166#[derive(Clone, Debug, PartialEq, Eq)]
167pub(super) struct LocalRemoteLocation {
168    mailbox: String,
169    uid_validity: u64,
170    uid: u64,
171}
172
173pub(super) fn active_remote_locations(
174    message: &MessageFile,
175    selected_folders: &BTreeSet<String>,
176) -> Vec<LocalRemoteLocation> {
177    let mut out = Vec::new();
178    if let Some(remote) = &message.remote {
179        for location in &remote.locations {
180            if location.missing_rfc3339.is_some()
181                || !selected_folders.contains(&location.mailbox_name)
182            {
183                continue;
184            }
185            if let (Some(uid_validity), Some(uid)) = (location.uid_validity, location.uid) {
186                push_unique_location(
187                    &mut out,
188                    LocalRemoteLocation {
189                        mailbox: location.mailbox_name.clone(),
190                        uid_validity,
191                        uid,
192                    },
193                );
194            }
195        }
196    }
197    out
198}
199
200pub(super) fn has_any_active_remote_location(message: &MessageFile) -> bool {
201    message.remote.as_ref().is_some_and(|remote| {
202        remote.locations.iter().any(|location| {
203            location.missing_rfc3339.is_none()
204                && location.uid_validity.is_some()
205                && location.uid.is_some()
206        })
207    })
208}
209
210pub(super) fn message_remote_flags(message: &MessageFile) -> Vec<String> {
211    let mut flags = Vec::new();
212    if let Some(remote) = &message.remote {
213        for location in &remote.locations {
214            if location.missing_rfc3339.is_none() {
215                flags.extend(location.flags.iter().cloned());
216            }
217        }
218    }
219    canonical_flags(flags)
220}
221
222pub(super) fn message_mailbox_ids(message: &MessageFile, config: &MailConfig) -> Vec<String> {
223    let mut ids = BTreeSet::new();
224    if let Some(remote) = &message.remote {
225        for location in &remote.locations {
226            if location.missing_rfc3339.is_some() {
227                continue;
228            }
229            if let Some(id) = &location.mailbox_id {
230                ids.insert(id.clone());
231                continue;
232            }
233            let matches = config.matching_mailbox_ids_offline(&location.mailbox_name);
234            if matches.is_empty() {
235                ids.insert(location.mailbox_name.clone());
236            } else {
237                ids.extend(matches);
238            }
239        }
240    }
241    ids.into_iter().collect()
242}
243
244pub(super) fn message_remote_missing_since_rfc3339(message: &MessageFile) -> Option<String> {
245    let mut values = Vec::new();
246    if let Some(remote) = &message.remote {
247        for location in &remote.locations {
248            if let Some(missing) = &location.missing_rfc3339 {
249                values.push(missing.clone());
250            }
251        }
252    }
253    values.sort();
254    values.into_iter().next()
255}
256
257pub(super) fn message_remote_missing(message: &MessageFile) -> bool {
258    message_remote_missing_since_rfc3339(message).is_some()
259        && !has_any_active_remote_location(message)
260}
261
262pub(super) fn message_remote_effect_pending(message: &MessageFile) -> bool {
263    message
264        .workspace
265        .push
266        .as_ref()
267        .is_some_and(|push| !push.pending.is_empty())
268}
269
270pub(super) fn push_unique_location(
271    locations: &mut Vec<LocalRemoteLocation>,
272    location: LocalRemoteLocation,
273) {
274    if !locations.iter().any(|existing| existing == &location) {
275        locations.push(location);
276    }
277}
278
279pub(super) fn remote_location_missing(
280    location: &LocalRemoteLocation,
281    snapshots: &BTreeMap<String, &crate::imap_pull::FolderUidSnapshot>,
282) -> bool {
283    let Some(snapshot) = snapshots.get(&location.mailbox) else {
284        return false;
285    };
286    snapshot.uid_validity != location.uid_validity || !snapshot.uids.contains(&location.uid)
287}
288
289pub(super) fn mark_remote_locations_missing(
290    message: &mut MessageFile,
291    missing: &[LocalRemoteLocation],
292) {
293    let now = now_rfc3339();
294    let remote = message
295        .remote
296        .get_or_insert_with(|| crate::types::RemoteState {
297            locations: Vec::new(),
298        });
299    for missing_location in missing {
300        if let Some(location) = remote.locations.iter_mut().find(|location| {
301            location.mailbox_name == missing_location.mailbox
302                && location.uid_validity == Some(missing_location.uid_validity)
303                && location.uid == Some(missing_location.uid)
304        }) {
305            if location.missing_rfc3339.is_none() {
306                location.missing_rfc3339 = Some(now.clone());
307            }
308        } else {
309            remote.locations.push(crate::types::RemoteLocation {
310                mailbox_id: None,
311                mailbox_name: missing_location.mailbox.clone(),
312                uid_validity: Some(missing_location.uid_validity),
313                uid: Some(missing_location.uid),
314                flags: Vec::new(),
315                observed_rfc3339: now.clone(),
316                missing_rfc3339: Some(now.clone()),
317            });
318        }
319    }
320}
321
322#[derive(Debug)]
323pub(super) struct ArchiveQueue {
324    pub(super) eligible_message_ids: Vec<String>,
325    pub(super) location_count: usize,
326    pub(super) queued_location_count: usize,
327    pub(super) items: Vec<crate::types::PushItem>,
328}
329
330#[derive(Debug)]
331pub(super) struct ArchiveEligibility {
332    eligible: bool,
333    blockers: Vec<String>,
334}
335
336#[derive(Debug, Clone)]
337pub(super) struct MailboxIdLocation {
338    mailbox_id: Option<String>,
339    push: PushLocation,
340}
341
342pub(super) fn resolve_location_mailbox_id(
343    config: &crate::config::MailConfig,
344    location: &MailboxIdLocation,
345) -> Result<Option<String>> {
346    if let Some(id) = &location.mailbox_id {
347        if config.mailboxes.contains_key(id) {
348            return Ok(Some(id.clone()));
349        }
350    }
351    let matches = config.matching_mailbox_ids_offline(&location.push.mailbox_name);
352    match matches.as_slice() {
353        [id] => Ok(Some(id.clone())),
354        [] => Ok(None),
355        _ => Err(AppError::new(
356            "imap_mailbox_ambiguous",
357            format!(
358                "remote mailbox {} matches multiple mailbox ids: {}",
359                location.push.mailbox_name,
360                matches.join(", ")
361            ),
362        )),
363    }
364}
365
366pub(super) fn add_queue_fields(value: &mut Value, location_count: usize, item: Option<&PushItem>) {
367    if let Value::Object(map) = value {
368        map.insert("location_count".to_string(), json!(location_count));
369        map.insert("queued_location_count".to_string(), json!(location_count));
370        map.insert("queued".to_string(), json!(item.is_some()));
371        map.insert(
372            "push_id".to_string(),
373            json!(item.map(|item| item.push_id.clone())),
374        );
375    }
376}
377
378impl Workspace {
379    pub(super) fn queue_archive_for_archived_messages(
380        &self,
381        message_ids: &[String],
382        allowed_push_path: Option<&Path>,
383    ) -> Result<ArchiveQueue> {
384        let mut eligible_message_ids = Vec::new();
385        for message_id in message_ids {
386            let eligibility = self.archive_eligibility(message_id, allowed_push_path)?;
387            self.write_archive_sync_state(message_id, eligibility.eligible)?;
388            if eligibility.eligible {
389                eligible_message_ids.push(message_id.clone());
390            }
391        }
392        if eligible_message_ids.is_empty() {
393            return Ok(ArchiveQueue {
394                eligible_message_ids,
395                location_count: 0,
396                queued_location_count: 0,
397                items: Vec::new(),
398            });
399        }
400        let config = crate::config::MailConfig::load(&self.root)?;
401        let locations = self.message_remote_locations_with_mailbox_ids(&eligible_message_ids)?;
402        let mut grouped: BTreeMap<String, Vec<PushLocation>> = BTreeMap::new();
403        for location in &locations {
404            let Some(source_id) = resolve_location_mailbox_id(&config, location)? else {
405                continue;
406            };
407            let Some(rule) = config
408                .actions
409                .message_archive
410                .by_source_mailbox_id
411                .get(&source_id)
412            else {
413                continue;
414            };
415            if rule.steps.is_empty() {
416                continue;
417            }
418            grouped
419                .entry(source_id)
420                .or_default()
421                .push(location.push.clone());
422        }
423        let queued_location_count = grouped.values().map(Vec::len).sum();
424        let mut items = Vec::new();
425        for (source_id, queue_locations) in grouped {
426            let mut queue_message_ids = Vec::new();
427            for location in &queue_locations {
428                merge_string(&mut queue_message_ids, &location.message_id);
429            }
430            let Some(rule) = config
431                .actions
432                .message_archive
433                .by_source_mailbox_id
434                .get(&source_id)
435            else {
436                continue;
437            };
438            if let Some(item) = crate::push_queue::queue_action_steps(
439                &self.root,
440                "message.archive",
441                &queue_message_ids,
442                &queue_locations,
443                &rule.steps,
444                None,
445            )? {
446                self.record_pending_push_item(&item)?;
447                items.push(item);
448            }
449        }
450        Ok(ArchiveQueue {
451            eligible_message_ids,
452            location_count: locations.len(),
453            queued_location_count,
454            items,
455        })
456    }
457
458    pub(super) fn message_remote_locations_with_mailbox_ids(
459        &self,
460        message_ids: &[String],
461    ) -> Result<Vec<MailboxIdLocation>> {
462        let mut locations = Vec::new();
463        for message_id in message_ids {
464            validate_id("message_id", message_id)?;
465            let message = self.read_message_by_id(message_id)?;
466            if let Some(remote) = message.remote {
467                for location in remote.locations {
468                    if location.missing_rfc3339.is_some() {
469                        continue;
470                    }
471                    if let (Some(uid_validity), Some(uid)) = (location.uid_validity, location.uid) {
472                        locations.push(MailboxIdLocation {
473                            mailbox_id: location.mailbox_id,
474                            push: PushLocation {
475                                message_id: message_id.clone(),
476                                mailbox_name: location.mailbox_name,
477                                uid_validity,
478                                uid,
479                            },
480                        });
481                    }
482                }
483            }
484        }
485        Ok(locations)
486    }
487
488    pub(crate) fn ensure_archive_eligible(
489        &self,
490        message_ids: &[String],
491        allowed_push_path: Option<&Path>,
492    ) -> Result<()> {
493        let mut blockers = Vec::new();
494        for message_id in message_ids {
495            let eligibility = self.archive_eligibility(message_id, allowed_push_path)?;
496            self.write_archive_sync_state(message_id, eligibility.eligible)?;
497            if !eligibility.eligible {
498                blockers.extend(eligibility.blockers);
499            }
500        }
501        if blockers.is_empty() {
502            Ok(())
503        } else {
504            Err(AppError::new(
505                "message_referenced",
506                format!(
507                    "message id cannot be archived remotely while blocked by {}",
508                    blockers.join(", ")
509                ),
510            )
511            .with_hint(
512                "Resolve the listed local references before pushing the remote archive move.",
513            )
514            .with_details(json!({
515                "blockers": blockers,
516                "suggested_commands": [
517                    "afmail status",
518                    "afmail push list",
519                    "afmail case show CASE_REF",
520                    "afmail archive message show ARCHIVE_REF"
521                ]
522            })))
523        }
524    }
525
526    pub(super) fn write_archive_sync_state(&self, message_id: &str, eligible: bool) -> Result<()> {
527        let mut msg = self.read_message_by_id(message_id)?;
528        msg.workspace.remote_sync = Some(RemoteSyncState {
529            archive_eligible: eligible,
530            checked_rfc3339: now_rfc3339(),
531        });
532        self.write_message_materialized_cache(&msg)
533    }
534
535    pub(super) fn archive_eligibility(
536        &self,
537        message_id: &str,
538        allowed_push_path: Option<&Path>,
539    ) -> Result<ArchiveEligibility> {
540        validate_id("message_id", message_id)?;
541        let message = self.read_message_by_id(message_id)?;
542        let cases = CaseIndex::build(self)?;
543        let mut blockers = Vec::new();
544        let has_archive_state = message.workspace.archive_uid.is_some()
545            || message.workspace.status == "archived"
546            || cases.has_archived_reference(message_id);
547        if !has_archive_state {
548            blockers.push(format!("messages/{message_id}.json:archive_uid"));
549        }
550        let ids = [message_id.to_string()]
551            .into_iter()
552            .collect::<BTreeSet<_>>();
553        self.collect_active_case_references(&ids, &mut blockers)?;
554        self.collect_draft_references(&ids, &mut blockers)?;
555        self.collect_push_references(&ids, allowed_push_path, &mut blockers)?;
556        Ok(ArchiveEligibility {
557            eligible: blockers.is_empty(),
558            blockers,
559        })
560    }
561
562    pub(crate) fn message_remote_locations(
563        &self,
564        message_ids: &[String],
565    ) -> Result<Vec<PushLocation>> {
566        self.message_remote_locations_inner(message_ids, true)
567    }
568
569    pub(crate) fn message_remote_locations_any(
570        &self,
571        message_ids: &[String],
572    ) -> Result<Vec<PushLocation>> {
573        self.message_remote_locations_inner(message_ids, false)
574    }
575
576    pub(super) fn message_remote_locations_inner(
577        &self,
578        message_ids: &[String],
579        inbound_only: bool,
580    ) -> Result<Vec<PushLocation>> {
581        let mut locations = Vec::new();
582        for message_id in message_ids {
583            validate_id("message_id", message_id)?;
584            let message = self.read_message_by_id(message_id)?;
585            if inbound_only && message.direction.as_deref() != Some("inbound") {
586                continue;
587            }
588            if let Some(remote) = message.remote {
589                for location in remote.locations {
590                    if location.missing_rfc3339.is_some() {
591                        continue;
592                    }
593                    if let (Some(uid_validity), Some(uid)) = (location.uid_validity, location.uid) {
594                        locations.push(PushLocation {
595                            message_id: message_id.clone(),
596                            mailbox_name: location.mailbox_name,
597                            uid_validity,
598                            uid,
599                        });
600                    }
601                }
602            }
603        }
604        Ok(locations)
605    }
606
607    pub(crate) fn add_remote_flags(
608        &self,
609        locations: &[PushLocation],
610        flags: &[String],
611    ) -> Result<()> {
612        self.update_remote_flags(locations, flags, true)
613    }
614
615    pub(super) fn update_remote_flags(
616        &self,
617        locations: &[PushLocation],
618        flags: &[String],
619        add: bool,
620    ) -> Result<()> {
621        let flags = canonical_flags(flags.iter().cloned());
622        for location in locations {
623            validate_id("message_id", &location.message_id)?;
624            let mut message = self.read_message_by_id(&location.message_id)?;
625            let state = message.remote.get_or_insert_with(|| RemoteState {
626                locations: Vec::new(),
627            });
628            let changed = if let Some(remote_location) =
629                state.locations.iter_mut().find(|remote_location| {
630                    remote_location.mailbox_name == location.mailbox_name
631                        && remote_location.uid_validity == Some(location.uid_validity)
632                        && remote_location.uid == Some(location.uid)
633                }) {
634                if add {
635                    merge_flags(&mut remote_location.flags, &flags)
636                } else {
637                    remove_flags(&mut remote_location.flags, &flags)
638                }
639            } else if add {
640                state.locations.push(RemoteLocation {
641                    mailbox_id: None,
642                    mailbox_name: location.mailbox_name.clone(),
643                    uid_validity: Some(location.uid_validity),
644                    uid: Some(location.uid),
645                    flags: flags.clone(),
646                    observed_rfc3339: now_rfc3339(),
647                    missing_rfc3339: None,
648                });
649                true
650            } else {
651                false
652            };
653            if changed {
654                self.persist_message_remote(&message)?;
655                self.write_message_materialized_cache(&message)?;
656            }
657        }
658        Ok(())
659    }
660
661    pub(super) fn message_id_is_referenced(&self, message_id: &str) -> Result<bool> {
662        match self.ensure_message_ids_unreferenced(&[message_id.to_string()], None) {
663            Ok(()) => Ok(false),
664            Err(err) if err.error_code == "message_referenced" => Ok(true),
665            Err(err) => Err(err),
666        }
667    }
668
669    pub(crate) fn ensure_message_ids_unreferenced(
670        &self,
671        message_ids: &[String],
672        allowed_push_path: Option<&Path>,
673    ) -> Result<()> {
674        let ids = message_ids.iter().cloned().collect::<BTreeSet<_>>();
675        let mut references = Vec::new();
676        self.collect_case_references(&ids, &mut references)?;
677        self.collect_push_references(&ids, allowed_push_path, &mut references)?;
678        self.collect_direct_archive_references(&ids, &mut references)?;
679        if references.is_empty() {
680            Ok(())
681        } else {
682            Err(AppError::new(
683                "message_referenced",
684                format!(
685                    "message id cannot be moved while referenced by {}",
686                    references.join(", ")
687                ),
688            )
689            .with_hint(
690                "Remove or move the listed case/archive/push references before moving the message.",
691            )
692            .with_details(json!({
693                "references": references,
694                "suggested_commands": [
695                    "afmail status",
696                    "afmail push list",
697                    "afmail case show CASE_REF",
698                    "afmail archive message show ARCHIVE_REF"
699                ]
700            })))
701        }
702    }
703
704    pub(super) fn collect_case_references(
705        &self,
706        ids: &BTreeSet<String>,
707        references: &mut Vec<String>,
708    ) -> Result<()> {
709        self.collect_any_case_message_references(ids, references)?;
710        self.collect_draft_references(ids, references)
711    }
712
713    pub(super) fn collect_any_case_message_references(
714        &self,
715        ids: &BTreeSet<String>,
716        references: &mut Vec<String>,
717    ) -> Result<()> {
718        for (_, case_path) in self.all_case_entries()? {
719            let messages_path = case_messages_json_path(&case_path);
720            if messages_path.exists() {
721                let data = read_to_string(&messages_path, "read case messages")?;
722                if json_contains_any_id(
723                    &serde_json::from_str::<Value>(&data)
724                        .map_err(|e| AppError::json("parse case messages", &e))?,
725                    ids,
726                ) {
727                    references.push(rel_path(&self.root, &messages_path));
728                }
729            }
730        }
731        Ok(())
732    }
733
734    pub(super) fn collect_active_case_references(
735        &self,
736        ids: &BTreeSet<String>,
737        references: &mut Vec<String>,
738    ) -> Result<()> {
739        for (_, case_path) in self.all_case_entries()? {
740            let messages_path = case_messages_json_path(&case_path);
741            if !messages_path.exists() {
742                continue;
743            }
744            let data = read_to_string(&messages_path, "read case messages")?;
745            let value = serde_json::from_str::<Value>(&data)
746                .map_err(|e| AppError::json("parse case messages", &e))?;
747            if !json_contains_any_id(&value, ids) {
748                continue;
749            }
750            if case_status(&case_path)? != "archived" {
751                references.push(rel_path(&self.root, &messages_path));
752            }
753        }
754        Ok(())
755    }
756
757    pub(super) fn collect_draft_references(
758        &self,
759        ids: &BTreeSet<String>,
760        references: &mut Vec<String>,
761    ) -> Result<()> {
762        for (_, case_path) in self.all_case_entries()? {
763            let drafts_dir = case_path.join("drafts");
764            if drafts_dir.exists() {
765                for entry in read_dir(&drafts_dir, "read drafts directory")? {
766                    let path = entry.path();
767                    if path.extension().and_then(|s| s.to_str()) != Some("md") {
768                        continue;
769                    }
770                    let text = read_to_string(&path, "read draft")?;
771                    let (fm, _) = read_doc::<DraftFrontmatter>(&text)?;
772                    if fm
773                        .reply_to_message_id
774                        .as_ref()
775                        .is_some_and(|id| ids.contains(id))
776                    {
777                        references.push(rel_path(&self.root, &path));
778                    }
779                }
780            }
781        }
782        Ok(())
783    }
784
785    pub(super) fn collect_push_references(
786        &self,
787        ids: &BTreeSet<String>,
788        allowed_push_path: Option<&Path>,
789        references: &mut Vec<String>,
790    ) -> Result<()> {
791        let push_dir = self.root.join(".afmail/push");
792        if !push_dir.exists() {
793            return Ok(());
794        }
795        for entry in read_dir(&push_dir, "read push queue")? {
796            let path = entry.path();
797            if path.extension().and_then(|s| s.to_str()) != Some("json")
798                || allowed_push_path.is_some_and(|allowed| allowed == path)
799            {
800                continue;
801            }
802            let data = read_to_string(&path, "read push item")?;
803            PushItem::parse_json(&data)?;
804            let value = serde_json::from_str::<Value>(&data)
805                .map_err(|e| AppError::json("parse push item", &e))?;
806            if json_contains_any_id(&value, ids) {
807                references.push(rel_path(&self.root, &path));
808            }
809        }
810        Ok(())
811    }
812
813    pub(super) fn collect_direct_archive_references(
814        &self,
815        ids: &BTreeSet<String>,
816        references: &mut Vec<String>,
817    ) -> Result<()> {
818        for message_id in ids {
819            let path = self.message_path(message_id);
820            let Ok(message) = self.read_message_by_id(message_id) else {
821                continue;
822            };
823            if message.workspace.archive_uid.is_some() {
824                references.push(format!("{}:archive_uid", rel_path(&self.root, &path)));
825            }
826        }
827        Ok(())
828    }
829}