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 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}