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