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